【実務・中級編】 OAuth 2.0におけるアクセストークンの有効期限とリフレッシュトークンの運用 – Web APIアーキテクチャ・データ連携実践ガイド

はじめに:なぜアクセストークンは「短命」でなければならないのか

ネットワークの世界に身を置く我々インフラエンジニアやバックエンドアーキテクトにとって、セキュリティと利便性のトレードオフは永遠の課題だ。ルーティングプロトコルにおける収束時間と安定性の関係に似て、認証・認可の設計においても「厳格なセキュリティ」と「ユーザー体験(UX)の向上」は常にせめぎ合っている。

Web APIのデファクトスタンダードとなったOAuth 2.0において、このジレンマを鮮やかに解決するのが「短命なアクセストークン」と「長命なリフレッシュトークン」の組み合わせ、そして昨今のセキュリティ要件におけるマストアイテム「リフレッシュトークンローテーション(Refresh Token Rotation)」だ。

もしアクセストークンの有効期限を数日や数週間に設定しているなら、今すぐその設計を見直してほしい。パケットキャプチャを覗けばわかるが、Authorizationヘッダーに載ったベアラートークン(Bearer Token)は、TLSで暗号化されているとはいえ、ひとたびストレージ(localStorage等)から窃取されれば、攻撃者にとっての「合鍵」として機能してしまう。

今回は、RFC 6749および最新のセキュリティベストプラクティスに基づき、この2つのトークンの役割分担と、現場で確実に実装するための通信フロー、そして具体的なコード実装までを徹底解説しよう。

—

1. 2つのトークンの役割とRFC 6749の基本思想

OAuth 2.0のアーキテクチャにおいて、アクセストークンとリフレッシュトークンは明確な役割分担を持っている。

  • アクセストークン(Access Token)
  • 寿命: 極めて短く(通常5分〜1時間程度)
  • 役割: リソースサーバー(API)へアクセスするための通行手形
  • 特性: ステートレスに検証できることが望ましい(JWTなど)。有効期限が短いため、万が一漏洩しても被害を最小限に抑えられる。
  • リフレッシュトークン(Refresh Token)
  • 寿命: 長く(数日〜数ヶ月、あるいは明示的なログアウトまで)
  • 役割: 認可サーバーに対して新しいアクセストークンを要求するための専用キー
  • 特性: 認可サーバーのデータベース等で厳重に管理され、アクセストークンよりも厳格に保護されるべきもの。

「なぜアクセストークンを毎回再発行させるのか?」という疑問を持つかもしれない。それは、BGPの経路ハイジャックやパケット盗聴といった最悪のシナリオを想定したとき、「漏洩したトークンの寿命を物理的に短くする」ことが、最も確実な防御壁になるからだ。

—

2. 通信フロー:アクセストークンの枯渇とリフレッシュのシーケンス

実際にクライアントサイド(SPAやモバイルアプリ)と認可サーバー、リフレッシュサーバーの間で何が起きているのか、その通信の裏側をシーケンスとして整理しておこう。

[Client / SPA]               [Resource Server (API)]       [Authorization Server]
      |                                |                               |
      |--- (1) GET /api/v1/data ------->|                               |
      |    (Authorization: Bearer AT)  |                               |
      |                                |                               |
      |--- (2) 401 Unauthorized ------>|  *(アクセストークン有効期限切れ)*
      |                                                                |
      |--- (3) POST /oauth/token ------------------------------------->|
      |    (grant_type=refresh_token & refresh_token=RT_old)           |
      |                                                                |
      |<-- (4) 200 OK (New AT & New RT) ------------------------------|
      |                                                                |
      |--- (5) GET /api/v1/data ------->|                               |
      |    (Authorization: Bearer New_AT)                              |
      |<-- (6) 200 OK (Data) ----------|                               |

1. クライアントが現在のアクセストークン(AT)を添えてAPIを叩く。
2. リフレッシュサーバー(またはAPIサーバー)がトークンの有効期限切れを検知し、401 Unauthorizedを返す。
3. クライアントはあらかじめ安全な場所に保持していたリフレッシュトークン(RT_old)を使い、認可サーバーのトークンエンドポイントへ新規発行をリクエストする。
4. 認可サーバーは正当性を検証し、新しいアクセストークンと新しいリフレッシュトークンを返す。
5. クライアントは新しいアクセストークンで再度APIを叩き、無事にデータを取得する。

—

3. リフレッシュトークンローテーション(RTR)の核心

ここで重要なのが、ステップ4における「新しいリフレッシュトークンの発行」だ。これがリフレッシュトークンローテーション(Refresh Token Rotation)の肝となる。

従来の設計では、一度発行されたリフレッシュトークンは有効期限が切れるまで使い回されていた。しかしこれでは、リフレッシュトークンがマルウェア等に窃取された場合、攻撃者は長期間にわたって新しいアクセストークンを無限に生成し続けることができてしまう。

ローテーションを有効にすると、「リフレッシュトークンが使われるたびに、古いものは無効化され、新しいリフレッシュトークンへと生まれ変わる」。

万が一の盗聴・再利用検知(Token Reuse Detection)

もし攻撃者が盗んだ古いリフレッシュトークンを使ってトークンエンドポイントを叩いたとしよう。正規のクライアントはすでにその先の新しいリフレッシュトークンを使っているはずだ。
認可サーバー側で「すでに無効化されたはずのリフレッシュトークンが使用された」という矛盾を検知した場合、認可サーバーは「重大なセキュリティインシデント(トークン泥棒の可能性)」と判断し、そのチェーンに属するすべてのリフレッシュトークンを即座に無効化(失効)し、ユーザーに強制再ログインを要求する。これにより、不正アクセスの連鎖を断ち切ることができるのだ。

—

4. 実装例:Python(FastAPI / Requests)におけるリフレッシュ処理

では、実務でこの仕組みをどう実装に落とし込むか。フロントエンドからのリクエストを代理で行うBFF(Backend For Frontend)パターンや、PythonスクリプトによるAPIクライアントを想定したコードを見てみよう。

以下のPythonコードは、HTTPクライアント(requests)を用いて、401エラーを検知した際に自動的にリフレッシュトークンを使ってアクセストークンを更新し、元のリクエストをリトライする堅牢な実装例だ。

import time
import requests

# エンドポイントの定義
TOKEN_ENDPOINT = "https://auth.example.com/oauth/token"
API_ENDPOINT = "https://api.example.com/v1/resource"

class OAuth2Session:
    def __init__(self, client_id, client_secret, access_token, refresh_token):
        self.client_id = client_id
        self.client_secret = client_secret
        self.access_token = access_token
        self.refresh_token = refresh_token

    def refresh_tokens(self):
        """
        リフレッシュトークンを使用してアクセストークンおよび
        リフレッシュトークンをローテーション(再発行)する
        """
        payload = {
            "grant_type": "refresh_token",
            "refresh_token": self.refresh_token,
            "client_id": self.client_id,
            "client_secret": self.client_secret,
        }
        
        response = requests.post(TOKEN_ENDPOINT, data=payload)
        
        if response.status_code == 200:
            data = response.json()
            self.access_token = data.get("access_token")
            
            # リフレッシュトークンローテーションにより、新しいRTが返却される
            if "refresh_token" in data:
                self.refresh_token = data.get("refresh_token")
                
            print("[INFO] トークンのローテーションに成功しました。")
            return True
        else:
            print(f"[ERROR] トークンのリフレッシュに失敗しました: {response.text}")
            # ここでリフレッシュトークン自体が失効している場合は再ログインを促す例外を送出する
            return False

    def guarded_request(self, method, url, **kwargs):
        """
        401エラーをハンドリングし、自動的にトークンをリフレッシュしてリトライするラッパー関数
        """
        # ヘッダーにアクセストークンを付与
        if "headers" not in kwargs:
            kwargs["headers"] = {}
        kwargs["headers"]["Authorization"] = f"Bearer {self.access_token}"

        response = requests.request(method, url, **kwargs)

        # アクセストークンが有効期限切れ(401 Unauthorized)の場合
        if response.status_code == 401:
            print("[WARN] アクセストークンの有効期限切れを検知。リフレッシュを試行します。")
            
            if self.refresh_tokens():
                # 新しいアクセストークンでヘッダーを更新
                kwargs["headers"]["Authorization"] = f"Bearer {self.access_token}"
                # リクエストを再試行(一度だけリトライ)
                response = requests.request(method, url, **kwargs)
            else:
                raise Exception("セッションが切れました。再認証が必要です。")

        return response

# --- 実行例 ---
if __name__ == "__main__":
    # 初期トークン(DBやセッションストアからロードした想定)
    session = OAuth2Session(
        client_id="my_cool_client",
        client_secret="super_secret_key",
        access_token="expired_or_short_lived_at",
        refresh_token="valid_long_lived_rt"
    )

    try:
        # APIへリクエスト送信
        res = session.guarded_request("GET", API_ENDPOINT)
        print(f"レスポンスステータス: {res.status_code}")
        print(f"レスポンスボディ: {res.json()}")
    except Exception as e:
        print(f"致命的なエラー: {e}")

—

5. 現場で役立つ運用のTipsとデバッグの勘所

現場でOAuth 2.0のトークン管理を運用する際、インフラエンジニアやバックエンドエンジニアが陥りがちな罠と、それを回避するための知見をいくつか共有しておこう。

1. トークンの保存先(Storage)の選択

  • ブラウザ(SPA)の場合: localStorageやsessionStorageへのアクセストークン/リフレッシュトークンの平文保存は、XSS(クロスサイトスクリプティング)脆弱性があった瞬間に一網打尽にされる。可能であれば、バックエンドをBFF(Backend For Frontend)構成にし、ブラウザからは直接トークンが見えないよう、HttpOnly属性がついたセキュアなCookieで管理するのが近年のベストプラクティスだ。
  • ネイティブアプリの場合: OSが提供するセキュアなストレージ(iOSのKeychain、AndroidのEncryptedSharedPreferences)を必ず使用すること。

2. クロック・スキュー(Clock Skew)への配慮

サーバー間の時刻同期が数秒ズレているだけで、「クライアント側では有効期限内だと思って送信したアクセストークンが、APIサーバー側では有効期限切れと判定される」という厄介な現象が起る。NTPによる厳密な時刻同期はもちろん、アクセストークンの検証時には数秒〜数十秒の猶予(Leeway)を持たせる実装がサーバー側に求められる。

3. 同時リクエスト(Concurrency)によるレースコンディション

SPAなどでページロード時に複数のAPIリクエストが同時に走り、そのすべてでアクセストークンが切れていた場合、クライアントは同時に複数のリフレッシュリクエストを認可サーバーへ送ってしまうことになる。
リフレッシュトークンローテーションを有効にしていると、最初のリクエストで発行された新しいリフレッシュトークンに切り替わるため、2番目以降のリクエストが古いリフレッシュトークンを送信してしまい、「トークンの再利用(不正)」とみなされてセッションが強制破棄される地獄のデバッグ案件が発生する。
これを防ぐため、「トークンリフレッシュ中は他のAPIリクエストをキューイングし、単一のリフレッシュ処理の完了を待たせる(またはロック機構を設ける)」というクライアント側の排他制御が実務では極めて重要になる。

—

おわりに

OAuth 2.0のアクセストークンとリフレッシュトークン、そしてローテーションの仕組みは、単なる「お作法」ではなく、Webアプリケーションの要塞を守るための最前線の防壁だ。

「面倒だからアクセストークンを長めにしておくか」という妥協が、将来的にセキュリティインシデントという名の甚大な障害を引き起こす。プロトコルの仕様を正しく理解し、パケットの往来やエラーハンドリングの隅々にまで気を配ることで、美しく堅牢なAPIアーキテクチャを築き上げてほしい。

コメント

タイトルとURLをコピーしました