ネットワークエンジニアとして数々の現場を渡り歩いてくると、Webアプリケーションのレイヤーで交わされるパケット、特にHTTPヘッダーの往来が手に取るように見えてくるものです。「なぜその設計にしたのか」という設計思想の美しさと、現場の泥臭いセキュリティ要件の妥協点が交差する瞬間こそ、アーキテクトとしての腕の見せ所と言えます。
今回は、現代のWeb API設計において避けて通れない「アクセストークンとリフレッシュトークンの有効期限設計」について、RFC(OAuth 2.0 / RFC 6749)の文脈をベースに、実務で即座に使える知見を徹底的に解説していきます。
教科書通りの綺麗事だけではなく、パケットが漏洩したときのリスクシナリオや、現場でよくある「うっかりハマりポイント」まで包み隠さずお伝えしましょう。
—
なぜ「1つのトークン」ではダメなのか? セキュリティとUXのジレンマ
API設計を始めたばかりのエンジニアによくあるのが、「一度発行したJWT(JSON Web Token)を長期間(例えば1年間)有効にして、クライアントにキャッシュさせれば効率が良いのでは?」という発想法です。
ネットワークスペシャリストの視点から言わせて貰えば、これは「バックドアを自ら常時開放しているようなもの」です。
もしその長命なトークンが、悪意あるサードパーティ製ブラウザ拡張機能や、誤ってパブリックなリポジトリにコミットされたコード片から流出したとしましょう。攻撃者は、有効期限が切れるまでの間、正当なユーザーになりすましてAPIを叩き放題になります。IPアドレスの制限やジオブロッキングをかけていたとしても、プロキシをローテーションさせられれば防ぎきれません。
だからこそ、OAuth 2.0の世界では「役割の分離( Separation of Concerns )」として、トークンを2つに分割するアーキテクチャが標準となっています。
1. アクセストークン(Access Token): 極めて短命(数分〜15分程度)。APIを実際に叩くための「入場パス」。
2. リフレッシュトークン(Refresh Token): 比較的長命(数日〜数週間)。アクセストークンが失効した際に、新しいアクセストークンを再発行するための「裏口の鍵」。
この二段構えにすることで、万が一短命なアクセストークンが盗まれても、被害のウィンドウ(時間的窓)を最小限に抑えることができるのです。
—
認証・再発行フローの全体像(シークエンスの理解)
まずは、クライアント(SPAやモバイルアプリ)と認可サーバー、そしてリソースサーバーの間で、どのようなパケットが流れているのかを整理します。
[Client / SPA] [Authorization Server] [Resource Server]
| | |
|--- 1. 認証リクエスト -------->| |
|<-- 2. トークン返却(A/R) ------| |
| (Access & Refresh) | |
| | |
|--- 3. APIリクエスト(Access) ->|---------------------------->|
|<-- 4. レスポンス(200 OK) -----|<----------------------------|
| | |
| --- (数分後: Access Token有効期限切れ) --- |
| | |
|--- 5. APIリクエスト(Expired)->|---------------------------->|
|<-- 6. レスポンス(401 Unauthorized)--------------------------|
| | |
|--- 7. トークンリフレッシュリクエスト(Refresh) -> |
|<-- 8. 新しいAccess Token返却 -| |
| | |
|--- 9. APIリクエスト(New Access)->--------------------------->|
|<-- 10. レスポンス(200 OK) ----|<----------------------------|
ポイントは、ステップ5と6で 401 Unauthorized を検知した瞬間に、クライアント側でバックグラウンドサイレントリフレッシュ(ステップ7・8)を走りさせ、ユーザーに再ログインを強いることなくシームレスに処理を継続させる点です。このUX(ユーザー体験)の滑らかさが、現代のWebアプリケーションの品質を左右します。
—
有効期限設計のベストプラクティスと実務パラメータ
現場で設計を行う際、よく議論になるのが「それぞれのトークンを何分・何日に設定すべきか」という具体的な数値です。RFC 6749やRFC 6819(OAuth 2.0 Threat Model and Security Considerations)の指針を踏まえ、私の現場での推奨値を以下に示します。
1. アクセストークンの有効期限
- 推奨値:
5分 〜 15分 - 理由: ステートレスなJWTを使用する場合、一度発行したアクセストークンは原則としてサーバー側で失効(レボケーション)させることが困難です(DBやRedisでブラックリストを持たない限り)。そのため、有効期限を極限まで短くし、万が一の漏洩時の被害を最小化します。
2. リフレッシュトークンの有効期限
- 推奨値:
14日 〜 30日(または「最終利用からN日間」のローリング方式) - 理由: あまりに短すぎると、ユーザーが数日アプリを開かなかっただけで頻繁に再ログインを求められ、UXが著しく低下します。セキュリティとUXのバランスを取るスイートスポットがこのあたりです。
—
実装例:セキュアなリフレッシュ処理のコードスニペット
では、実際にフロントエンド(JavaScript / Fetch API)とバックエンド(Python / FastAPI等)を想定した実装パターンを見ていきましょう。
フロントエンド側の実装(Axios インターセプターやFetchのラッパー)
ブラウザの localStorage にアクセストークンを保存するのは、XSS(クロスサイトスクリプティング)脆弱性に対する耐性がゼロになるため、現代のセキュリティ基準ではNGとされています。アクセストークンはメモリ(変数やクロージャ内)に保持し、リフレッシュトークンは HttpOnly 属性がついたセキュアなCookieでやり取りするのが鉄則です。
以下は、401 エラーを検知した際に自動でトークンをリフレッシュし、元のリクエストをリトライする堅牢なFetchラッパーの例です。
// APIリクエストを実行する共通関数(自動リフレッシュ機能付き)
async function fetchWithAutoRefresh(url, options = {}) {
// 1. 初回リクエストの送信(メモリ上のアクセストークンをAuthorizationヘッダーに付与)
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${getAccessTokenFromMemory()}`
}
});
// 2. アクセストークンの有効期限切れ(401 Unauthorized)を検知した場合
if (response.status === 401) {
console.warn('アクセストークンの有効期限切れを検知。リフレッシュを試行します...');
try {
// 3. リフレッシュトークン(HttpOnly Cookieに自動付与される想定)を用いて新トークンを要求
const refreshResponse = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include' // Cookieを確実に送信するため
});
if (!refreshResponse.ok) {
throw new Error('リフレッシュトークンも無効です。再ログインが必要です。');
}
const data = await refreshResponse.json();
const newAccessToken = data.access_token;
// 4. 新しいアクセストークンをメモリ上に保存
setAccessTokenToMemory(newAccessToken);
console.log('アクセストークンの更新に成功しました。');
// 5. 失敗した元のリクエストを新しいアクセストークンでリトライ
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newAccessToken}`
}
});
} catch (refreshError) {
console.error('認証セッションが完全に失効しました:', refreshError.message);
// ログイン画面へ強制リダイレクトする処理などをここに記述
redirectToLogin();
throw refreshError;
}
}
return response;
}
バックエンド側の実装(Python / FastAPI)
次に、リフレッシュトークンを受け取り、検証の上で新しいアクセストークンを発行するバックエンドのサンプルコードです。ここではリフレッシュトークンの「使い捨て(ローテーション)」を実装しています。
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends
from pydantic import BaseModel
import jwt
app = FastAPI()
# 実際の環境では環境変数やセキュアな秘密鍵ストアから取得すること
SECRET_KEY = "super-secret-key-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
@app.post("/api/v1/auth/refresh", response_model=TokenResponse)
def refresh_access_token(
response: Response,
refresh_token: str = Cookie(None, alias="refresh_token")
):
"""
HttpOnly Cookieからリフレッシュトークンを受け取り、
検証後に新しいアクセストークンを発行するエンドポイント
"""
if not refresh_token:
raise HTTPException(status_code=401, detail="リフレッシュトークンが存在しません")
try:
# トークンのデコードと署名検証
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
# トークンの種別がリフレッシュ用であるか厳密にチェック
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="無効なトークンタイプです")
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=401, detail="無効なトークンペイロードです")
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="リフレッシュトークンの有効期限が切れています。再ログインしてください。")
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="トークンの検証に失敗しました")
# 新しいアクセストークン(短命)の生成
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_jwt(
data={"sub": username, "type": "access"},
expires_delta=access_token_expires
)
# 【重要なセキュリティTips】
# リフレッシュトークン自体も毎回新しく発行してCookieを上書きする
# 「リフレッシュトークンのローテーション(Token Rotation)」を実装することで、
# 万が一古いリフレッシュトークンが二重に使われた場合に「盗難」と検知できる。
new_refresh_token = create_jwt(
data={"sub": username, "type": "refresh"},
expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
)
response.set_cookie(
key="refresh_token",
value=new_refresh_token,
httponly=True,
secure=True, # 本番環境では必ずTrue (HTTPS必須)
samesite="lax",
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
return {"access_token": access_token, "token_type": "bearer"}
def create_jwt(data: dict, expires_delta: timedelta):
to_encode = data.copy()
expire = datetime.utcnow() + expires_delta
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
—
現場で役立つ実践的Tips:リフレッシュトークンのローテーションと検知
実務のインフラ・セキュリティ監査において、単にリフレッシュトークンを長期間使い回す設計は、徐々にレガシーなものとみなされつつあります。
より高度で堅牢なシステムでは、「リフレッシュトークンのローテーション(Token Rotation)」と「ファミリーID(Reuse Detection)」の概念を取り入れます。
- ローテーションの仕組み: リフレッシュトークンが使われるたびに、古いリフレッシュトークンをサーバー側(DBやRedis)で無効化し、新しいリフレッシュトークンを発行します。
- 盗難検知(Reuse Detection): もし攻撃者が古い(すでに無効化された)リフレッシュトークンを使って再発行を試みた場合、サーバー側は「このトークンはすでに使用済みなのに、再度使われたということは、正規ユーザーか攻撃者のどちらかが不正に複製している」と即座に検知できます。この場合、サーバーはそのユーザーの「同一ファミリー(同じセッションツリー)に属するすべてのリフレッシュトークンを即座に無効化(Revoke)」し、強制的にログアウトさせます。
—
まとめ:美しく、そして堅牢なAPI設計を目指して
アクセストークンとリフレッシュトークンの有効期限設計は、単なる「仕様のパズル」ではありません。トラフィックの効率性、サーバーのリソース、そして何よりもユーザーの資産とプライバシーを守るための、防衛ラインそのものです。
「短命なアクセストークンで被害範囲を狭め、長命なリフレッシュトークンを安全なCookieとローテーション機構で守る」——この黄金律を意識するだけで、あなたの構築するWeb APIは、プロフェッショナルが唸るほど美しく、かつ鉄壁の堅牢性を備えたものに生まれ変わります。
インフラの向こう側にいるユーザーの安全なセッションを守り抜くため、細部までこだわり抜いたアーキテクチャ設計を続けていきましょう。
コメント