Skip to content

필사 모드: JWTとセッション、何をいつ — 状態をどこに置くかで整理する認証の選択

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — JWTは拡張性がよいという言葉の実際の意味

認証方式を決めるときに最も頻繁に登場する文があります。「セッションはサーバーに状態を置くから拡張できず、JWTはステートレスだから拡張しやすい」というものです。この文は半分だけ正しいです。そして間違っている半分のほうが、実務ではずっと高い代償を払わせます。

まず事実確認からです。JWTは暗号化されません。base64urlでエンコードされているだけなので誰でも読めます。

echo 'eyJzdWIiOiJ1c2VyXzg4MTIiLCJyb2xlIjoiYWRtaW4iLCJlbWFpbCI6ImtpbUBleGFtcGxlLmNvbSIsImV4cCI6MTc4NDA1NjAwMH0' \
  | base64 -d
{"sub":"user_8812","role":"admin","email":"kim@example.com","exp":1784056000}

署名が保証するのは偽造不可であって秘密保持ではありません。だからペイロードにメールアドレス、電話番号、内部識別子を入れるのは、ブラウザのローカルストレージに個人情報を平文で保存するのと同じです。JWEで暗号化しないかぎり、ペイロードは公開データだと考えるべきです。

この記事は二つの方式のどちらを選ぶかの基準を扱います。結論を先に言うと、ブラウザを相手にするたいていのウェブアプリケーションにはセッションクッキーのほうがよく、JWTはサービス間通信とステートレスが本当に必要な場所に使います。

根本的な違いはひとつだ — 状態をどこに置くか

セッション方式では、サーバーがセッションストアに認証状態を持ち、クライアントは意味のない識別子だけをクッキーで持ちます。リクエストが来ると、サーバーはその識別子でストアを照会します。照会結果がなければ認証失敗です。

JWT方式では、サーバーは何も持たず、クライアントが署名されたクレームそのものを持ちます。リクエストが来ると、サーバーは署名を検証して有効期限を確認します。ストアの照会がありません。

ここからすべてが分かれます。セッションは認証判断の根拠がサーバーにあるので、サーバーがいつでも変えられます。JWTは根拠がすでにクライアントの手に出ているので、サーバーが回収できません。性能も保存場所も、この違いの結果にすぎません。

項目セッションクッキーJWT (ステートレス)短い アクセストークン + リフレッシュ
状態の位置サーバーのストアクライアントクライアント + リフレッシュ用のサーバーストア
リクエストあたりの費用ストア照会1回 (Redis基準で1ms未満)署名検証 (HMACはマイクロ秒単位)署名検証。更新周期ごとにだけストア照会
即時無効化可能。レコード削除不可能。有効期限まで有効アクセストークンの寿命ぶん遅延 (通常5分から15分)
権限変更の反映即時期限切れ後更新時点
サイズ32バイト前後の識別子300から1000バイト。毎リクエスト送信アクセストークンのサイズと同じ
主な露出経路CSRFXSS (保存場所による)保存設計によって変わる
複数ノードでの拡張共有ストアが必要共有ストアは不要更新経路にだけ共有ストアが必要
検証できる主体サーバー本人秘密鍵か公開鍵を持つ誰でもアクセストークンは誰でも、更新は発行者だけ

表の最後の行がJWTの本当の存在理由です。セッション識別子はストアにアクセスできる主体しか解釈できませんが、JWTは公開鍵さえあれば誰でも検証できます。発行者と検証者が別の組織だったり別のシステムだったりするとき、この性質は代替不可能です。

JWTの最大の弱点 — 即時無効化ができない

アカウントが乗っ取られてユーザーがパスワードを変えたとしましょう。セッションなら、そのユーザーのセッションレコードを全部消せば終わりです。次のリクエストから即座にログアウトされます。

JWTなら消す対象がありません。攻撃者が持っているトークンは有効期限まで有効です。有効期限を24時間にしていたなら、最大24時間のあいだ攻撃者はログイン状態を保ちます。管理者権限を回収しても、アカウントを停止しても、決済を止めても同じです。サーバーが検証するのは署名とexpだけだからです。

現実的な対応は三つの組み合わせになります。

第一に、アクセストークンの寿命を短くします。5分から15分が一般的です。露出の窓をそのぶん狭める方法です。

第二に、リフレッシュトークンで更新します。リフレッシュトークンはサーバーのストアに記録されていて、更新時点で有効性を確認します。ローテーションを併用します。一度使ったリフレッシュトークンは即座に破棄して新しいものを発行し、すでに使われたトークンが再び入ってきたら盗難とみなしてその系列全体を破棄します。

async function rotateRefreshToken(presented) {
  const record = await db.refreshTokens.findByHash(sha256(presented))
  if (!record) throw new AuthError('unknown token')

  if (record.usedAt) {
    // すでに使われたトークンがまた来た = コピーが出回っている
    await db.refreshTokens.revokeFamily(record.familyId)
    throw new AuthError('reuse detected, family revoked')
  }

  await db.refreshTokens.markUsed(record.id)
  return issuePair({ userId: record.userId, familyId: record.familyId })
}

第三に、ブラックリストを置きます。無効化したトークンのjtiを保存しておき、毎リクエストで照合します。

ところがここに、正直に押さえておくべき点があります。ブラックリストを置いた瞬間にすべてのリクエストが共有ストアを照会することになり、それはセッションとまったく同じ構造です。ストア障害が認証障害になるのも、ノードごとにストアへつながなければならないのも同じです。違うのはセッションより部品が多く、トークンが大きいことだけです。ステートレスの利点をなくしながら複雑さだけを残す選択になります。

だから実際に使える中間地点は、短いアクセストークンと保存されたリフレッシュトークンの組み合わせです。ストア照会を毎リクエストではなく更新周期ごとに一度へ減らしつつ、無効化の遅延をアクセストークンの寿命以内に縛ります。この設計を選ぶときは、その遅延がビジネス的に許容できるかをまず答える必要があります。金融取引の権限のように秒単位の回収が必要な場所なら許容できません。

トークンをどこに保存するか

この問いはXSSとCSRFのどちらを引き受けるかにまとめられがちですが、正確な比較は少し違います。

localStorageに入れると、同じオリジンのすべてのスクリプトが読めます。XSSが一度起きればトークンが丸ごと攻撃者のサーバーへ送られ、攻撃者はそのトークンでブラウザの外から自由にAPIを呼べます。有効期限まで有効で、ユーザーがログアウトしても無駄です。XSSひとつがそのまま資格情報の流出です。

httpOnlyクッキーに入れると、スクリプトは値を読めません。XSSが起きてもトークン自体は外へ出ません。ただし攻撃者のスクリプトがそのページの中からリクエストを飛ばせばクッキーは自動的に載るので、ユーザーの代わりに行動することはできます。これは明確な被害ですが、資格情報が流出して任意の場所から無期限に使われるのとは被害の規模が違います。攻撃の窓がそのセッションとそのページに限られます。

クッキーを使うならCSRFを処理しなければなりません。属性設定が一次防御です。

Set-Cookie: sid=8f3c...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600

SameSite=LaxはクロスサイトのPOSTにクッキーを載せず、トップレベルナビゲーションのGETにだけ載せます。Chromiumが既定値として採用したことで、フォームベースのCSRFのかなりの部分が自動的に防がれました。Strictは外部リンクをたどって入ってきたGETにもクッキーを載せないので、ログイン状態が切れたように見えます。Noneはクロスサイト送信を許可しSecureが必須ですが、この場合はCSRFトークンが必ず必要になります。

ここに重要な落とし穴があります。SameSiteはオリジンではなくサイト単位で判断します。app.example.comとapi.example.comは別オリジンですが同じサイトです。サブドメインのひとつにXSSが生まれると、SameSiteは何の防御もしません。ユーザーコンテンツをサブドメインにホスティングするサービスなら特に危険です。

SPAで広く使われる折衷案は、アクセストークンをJavaScriptのメモリ変数にだけ置き、リフレッシュトークンをhttpOnlyクッキーとして更新経路にだけスコープすることです。

// アクセストークンはモジュールスコープ変数にだけ。リロードすると消えます
let accessToken = null

async function refresh() {
  // リフレッシュクッキーはPath=/auth/refreshにスコープされ、このリクエストにだけ載ります
  const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
  if (!res.ok) throw new AuthError('refresh failed')
  accessToken = (await res.json()).access_token
}

リロードのたびに更新リクエストが一度出ていく代わりに、XSSが起きても持続可能な資格情報はスクリプトの手に渡りません。

ひとつ付け加えると、XSSが発生した状況で完全な防御はありません。保存場所の議論は被害を減らす話であって防ぐ話ではありません。Content-Security-Policyと出力エスケープが根本対策であり、保存場所はその次です。

実装の脆弱性 — 署名は検証したときにだけ意味がある

JWTは仕様が柔軟なので、ライブラリの使い方を少し外すだけで署名が無力化されます。古典になった事例たちです。

algヘッダーをnoneに変えて送ると署名なしで通ってしまうライブラリがありました。攻撃者はペイロードのroleをadminに変えて署名部分を空にしたトークンを作ればそれで済みました。古い話のようですが、検証時にアルゴリズムをトークンヘッダーから読み取るコードは今も出てきます。

アルゴリズム混同はもっと巧妙です。RS256で発行するサーバーで検証関数がアルゴリズムを固定していないと、攻撃者はHS256にヘッダーを変え、サーバーの公開鍵をHMACの秘密鍵として使って署名したトークンを作ります。公開鍵は名前のとおり公開されているので誰でも作れます。

最も多いのは検証をまったくしない間違いです。

// やらないでください — decodeは署名を検証しません
const payload = jwt.decode(token)
if (payload.role === 'admin') grantAdmin()
// 検証時にアルゴリズム、発行者、対象をサーバーが固定します
import { jwtVerify } from 'jose'

const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'], // トークンに選ばせません
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
  clockTolerance: 5,
})

audを検証しないと、別サービス向けに発行されたトークンが自分たちのAPIで通ります。同じ認証サーバーを使う内部サービスが複数あるなら実際に起きることです。issの未検証も同じ性格です。

HMACを使うとき秘密鍵が短いと、オフラインで総当たりに破られます。辞書の単語や既定値をそのままにしていた事例が公開サービスでも見つかります。最低256ビットのランダム値を使い、コードやリポジトリではなくシークレット管理システムに置いてください。

kidヘッダーで鍵を探す実装は、その値が攻撃者の入力だという点を忘れてはいけません。ファイルパスやSQL照会にそのまま入れるとパストラバーサルとインジェクションが生まれます。既知の鍵識別子の集合と照合したうえでだけ使ってください。

セッションが実際に拡張の問題になる時点

セッションは拡張に不利だという通念を数字で確認してみます。

セッションレコードひとつはユーザー識別子、発行時刻、有効期限、少しのメタデータからなります。多めに見積もって200バイトから500バイトです。同時セッション100万個なら200MBから500MBです。Redisインスタンスひとつが余裕で耐えられるサイズです。1000万個でも数GBであり、この規模のサービスならすでにRedisクラスタを運用しているはずです。

照会の費用はキーひとつに対するO(1)照会です。同一リージョン内のRedis往復はふつう1ms未満で、単一インスタンスが毎秒数万から十万単位のコマンドを処理します。アプリケーションがリクエストごとに行う他のDBクエリと比べれば無視できる比重です。

では、セッションが実際に問題になる地点はどこか。三つあります。

プロセスメモリにセッションを置き、スティッキーセッションで貼り付けている場合です。デプロイのたびにログインが切れ、ノード間の負荷が均等になりません。これはセッションの問題ではなくストア選択の問題であり、共有ストアへ移せば解決します。

リージョンが複数ある場合です。ヨーロッパのユーザーのリクエストがセッション照会のために米国リージョンまで往復すると、数百ミリ秒が追加されます。リージョンごとのストアとレプリケーション戦略が必要になります。この地点からはステートレストークンが実質的な利点を持ちます。

検証する主体が自分たちではない場合です。パートナー企業が自分たちのトークンを検証しなければならないのに、自分たちのセッションストアを開放するわけにはいきません。公開鍵検証が答えです。

整理すると、セッションが拡張に不利になる時点は、たいていのチームが考えているよりずっと遅く来ます。その時点が来る前にJWTを選ぶと、得られなかった拡張性の代わりに無効化不可能という負債だけを先に抱え込みます。

実務の推奨 — 何をいつ

同じドメインで動くウェブアプリケーションならセッションクッキーが既定値です。httpOnly、Secure、SameSite=Laxを付け、ストアはRedisかDBを使います。ログアウトと権限回収が即座に反映され、部品が少なく、トークンサイズの心配がありません。サブドメイン間の共有が必要ならDomain属性で解決します。

モバイルアプリやサードパーティクライアントがつながる自社APIなら、短いアクセストークンとローテーションするリフレッシュトークンを使います。クッキーを使えない環境なのでトークンが必要であり、リフレッシュ経路に状態を置いて無効化の手段を確保します。リフレッシュトークンはOSのセキュアストレージに入れます。

サービス間通信にはJWTがよく合います。呼び出しごとに認証サーバーへ往復せず公開鍵で検証でき、スコープを狭く取って短く発行すれば無効化の問題は実質的に消えます。寿命が数十秒単位なら、破棄する理由自体がほとんどありません。

外部のアイデンティティプロバイダを使う場合、OIDCのIDトークンはJWTです。ただしIDトークンは身元を証明する用途であって、APIアクセスの資格情報ではありません。IDトークンをそのままAPI認証に使うのはよくある誤用です。

最後に避けるべき組み合わせがあります。有効期限の長いJWTをlocalStorageに入れ、ログアウトはクライアントで値を消すことで処理する設計です。ログアウトボタンが何も無効化せず、XSSが一度あれば長期の資格情報が丸ごと出ていきます。最も広く広まったチュートリアルの形でありながら、最も脆弱な構成です。

おわりに — ステートレスがただなのかをまず確認する

JWTを選ぶときに投げるべき問いは「拡張がうまくいくか」ではなく「この資格情報を回収できなくても大丈夫か」です。大丈夫ならステートレスの利点を丸ごと享受できます。大丈夫でないなら何らかの形で状態を再び持ち込む必要があり、その瞬間セッションに対する優位はほとんど消えます。

認証設計の最初の決定はトークン形式ではなく無効化の要件であり、それが状態をどこに置くかを決めます。ブラウザを相手にする平凡なウェブサービスなら、セッションクッキーで始めるほうがほぼ常に正しいです。あとでステートレスが本当に必要になったときに移す費用のほうが、最初からJWTで始めて無効化の事故を経験する費用より安く済みます。

현재 단락 (1/90)

認証方式を決めるときに最も頻繁に登場する文があります。「セッションはサーバーに状態を置くから拡張できず、JWTはステートレスだから拡張しやすい」というものです。この文は半分だけ正しいです。そして間違っ...

작성 글자: 0원문 글자: 7,465작성 단락: 0/90