アカウントを止めたのに、トークンはまだ使える
退職者のアカウントを夕方に止めたのに、翌朝まで顧客データを読めた。トークンが発行されてから使えなくなるまでに通る6つの関門を、順に点検する。
事件:止めたはずのアカウントで、翌朝まで読めた
社内の顧客管理システムで、退職者のアカウントを夕方に停止した。翌朝のログを見ると、そのアカウントのトークンで、夜のうちに顧客データが読まれていた。
アクセストークンは24時間有効のJWTで、APIは署名と期限だけを確かめていた。停止は、発行済みのトークンに何の影響も与えていなかった。
こうした事故の原因は、1か所に絞れないことが多い。トークンは、発行されてから使えなくなるまでに6つの関門を通る。
受け取る → 確かめる → データに触れる → 判定をキャッシュする → 取り直す → 取り消す
受け取る:認可コードを、要求したアプリに結びつける
OAuthの認可コードフローでは、アプリが短命の認可コードを受け取り、トークンと交換する。サーバーのアプリは交換にclient secretを使うが、SPAやモバイルアプリは秘密を持てない。配った中身から、誰でも取り出せるからだ。
PKCEでは、アプリがログインのたびにランダムなcode_verifierを作り、そのハッシュだけを最初の要求に付ける。交換のときにcode_verifierを出せるのは、要求したアプリだけだ。横取りされた認可コードは、交換で拒まれる。
// 32バイトの乱数をBase64URLにすると43文字。ログインのたびに作り直す
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
sessionStorage.setItem("pkce_verifier", verifier);
// 認可の要求には challenge だけを付ける
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: challenge,
code_challenge_method: "S256",
state,
});
location.assign(`${authorizeUrl}?${params}`);| 方式 | 横取りされたら |
|---|---|
| implicitフロー | トークンがそのまま使われる(今は使わない) |
| 認可コード + 埋め込んだclient secret | アプリを展開すればsecretが読める |
| 認可コード + PKCE | code_verifierがないので交換で拒まれる |
確かめる:署名が正しくても、自分宛てとは限らない
1つのIdPは、多くのアプリに同じ鍵で署名したトークンを発行する。署名が示すのは「このIdPが発行した」ことだけで、「自分のAPI向けに発行した」ことではない。
署名と期限だけを確かめていた会社では、誰でも登録できる勉強会用のアプリでログインして得たトークンで、人事のAPIから給与のデータを読めた。宛先は aud に、発行者は iss に入っている。
token, err := jwt.Parse(raw, keyFunc, // keyFuncはIdPの公開鍵(JWKS)を返す
jwt.WithValidMethods([]string{"RS256"}), // ヘッダーのalgには従わない
jwt.WithIssuer("https://idp.example.com/"), // 発行者が完全に一致する
jwt.WithAudience("https://hr-api.example.com"), // 自分宛てである
jwt.WithExpirationRequired(), // 期限のないトークンは拒む
)サービスごとに別のaudでトークンを発行し、1つのトークンですべてのAPIを呼べる作りにしない。
データに触れる:ログインしていても、見てよいとは限らない
2,000社が使う請求書のSaaSで、APIは /invoices/1002 のようにIDを受け取り、ログインしているかだけを確かめていた。URLの番号を1つ変えると、他社の請求書が見えた。 これがBOLA(オブジェクト単位の認可の不備)だ。
IDをUUIDにすれば推測はしにくくなるが、共有リンクやログからIDが漏れれば同じことが起きる。データアクセス層でテナントIDを必ず付け、DBの行レベルセキュリティを最後の防壁にする。
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- テーブルの持ち主にも適用する
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- 検証済みトークンのテナントIDを、このトランザクションだけに設定する
BEGIN;
SELECT set_config('app.tenant_id', $1, true);
SELECT * FROM invoices WHERE id = $2; -- 他社の請求書は0行になる
COMMIT;テナントIDは、URLや本文ではなく検証済みのトークンから取る。前の関門でaudとissを確かめていなければ、そのIDも信用できない。設定を忘れたクエリは current_setting がエラーになるので、全件が見える側ではなく失敗する側に倒れる。
判定をキャッシュする:外した直後の許可が残る
権限の判定は回数が多いので、キャッシュから返すのがふつうだ。判定を5分キャッシュしていた社内のドキュメント共有で、異動した人をプロジェクトから外した直後に、人事評価の資料がそのフォルダーに入った。キャッシュには外す前の「読める」が残っていて、4分後に資料を開けた。
すべての判定を最新にすると遅すぎる。代わりに、資料を書いたときの権限の版(SpiceDBのZedToken)を資料と一緒に保存し、読むときはその版以上のデータで判定させる。変わっていない資料は、キャッシュの速い判定のままでよい。
- アリス: ボブを共有から外す
- 認可サービス: 権限の版がv2になる
- アリス: 資料を保存し、版v2も一緒に保存する
- ボブ: 資料を開こうとする
- 認可サービス: キャッシュはv1だが、v2以上で判定する
- ボブ: 読めない
取り直す:リフレッシュトークンは使うたびに替える
アクセストークンの期限を短くしても、長いセッションはリフレッシュトークンで保たれる。そのリフレッシュトークンが盗まれると、攻撃者は期限が切れるたびに取り直し続けられる。
そこで、使うたびに新しいものに替え(ローテーション)、古いものは無効にする。盗んだ側と正規のアプリのどちらかが使用済みのトークンを出すことになるので、それを検知したら、同じログインから続く一式をまとめて無効にする。
落とし穴は、正規のアプリが自分で使用済みのトークンを出す場合だ。タブを3つ開いた利用者は、3つのタブが同時に取り直し、2つ目以降が再利用と判定されて、毎日のようにログアウトされた。
let refreshing: Promise<void> | undefined;
export function refreshTokens(): Promise<void> {
// 同じタブの並列のリクエストは、進行中の1回を待つ
refreshing ??= navigator.locks
.request("token-refresh", async () => {
// ロックを待つ間に、ほかのタブが取り直しているかもしれない
if (!expiresSoon(storedTokens())) return;
await exchangeRefreshToken();
})
.finally(() => {
refreshing = undefined;
});
return refreshing;
}通信の切れに備えて、短い猶予期間(Oktaの既定は30秒)を置くと、誤ったログアウトはほぼなくなる。
取り消す:JWTは期限まで止められない
最後の関門が、事件の直接の原因だ。JWTは認証サーバーに問い合わせずに検証できる代わりに、発行したものを後から無効にする仕組みがない。アカウントを止めても、パスワードを変えても、期限まで通る。
だから期限は「止めたあと、どれだけ使われてよいか」で決め、取り消しはリフレッシュトークンで行う。期限を短くするほど穴は小さくなり、取り直しは増える。
| アクセストークンの期限 | 止めたあと使える時間 | 1日8時間使う人の取り直し |
|---|---|---|
| 24時間 | 最大24時間 | 1日1回以下 |
| 1時間 | 最大1時間 | 1日8回 |
| 15分 | 最大15分 | 1日32回 |
| 5分 | 最大5分 | 1日96回 |
送金や権限の変更のように、すぐ止めたい操作だけは、遅延を払ってでもサーバーで失効を確かめる。Firebase Authenticationの verifyIdToken に checkRevoked を渡すのがこれにあたる。
6つの関門のまとめ
| 関門 | 確かめ忘れると | 守り方 |
|---|---|---|
| 受け取る | 横取りした認可コードでトークンを取られる | PKCE |
| 確かめる | 別のアプリ向けのトークンが通る | audとiss |
| データに触れる | IDを変えるだけで他人のデータが見える | 持ち主の確認、行レベルセキュリティ |
| 判定をキャッシュする | 外した直後の資料が見える | 権限の版を資料と保存 |
| 取り直す | 盗まれたリフレッシュトークンが使われ続ける | ローテーションと再利用の検知 |
| 取り消す | 止めたあとも期限まで使える | 短い期限、リフレッシュトークンの取り消し |
面接で「JWTでログアウトはどう実装しますか?」と聞かれたら
まず前提を言う。「署名だけで検証するJWTは、期限まで取り消せません」。そのうえで次の順に答える。
- アクセストークンの期限を短くする(たとえば15分)
- 取り消しは、サーバーが管理するリフレッシュトークンで行う
- 停止やパスワード変更では、リフレッシュトークンを一式取り消す
- 送金のような重要な操作だけ、サーバーで失効を確かめる
- リフレッシュトークンはローテーションし、再利用を検知する
期限の根拠として、「止めたあと何分使われてよいか」という問いを自分から立てると、数字の選び方に理由が付く。