はじめに
リフレッシュトークンの「盗まれたら全部無効にする」処理を書いたつもりが、その無効化だけがなかったことになっていました。テストが教えてくれなければ、たぶん気づかないままでした😅
先に結論:@Transactional は既定で RuntimeException が飛ぶとロールバックします。そのため「危険を検知したので防御しました。そして例外を投げます」という順番で書くと、防御の書き込みごと巻き戻ります。
対処は noRollbackFor で「この例外のときはロールバックしない」と伝えることでした。
環境
- Java 25(Gradle の toolchain で取得)/ Gradle 9.6.1
- Spring Boot 3.5.14(Spring Data JPA)
- PostgreSQL 16(テストは Testcontainers で起動)
- 2026年7月時点
やりたかったこと
リフレッシュトークンは、使うたびに古いものを失効させて新しいものを配ります(rotation)。
このとき、すでに失効済みのトークンがもう一度出てきたら、何かがおかしい。盗まれて使い回されている疑いがあります(実際には通信の再送や並行リクエストでも起こりうるので、あくまで「疑い」です)。
そこで、こう決めました。
- 失効済みトークンの再提示を見つけたら、そのユーザーの有効なトークンをすべて失効させる
- そのうえで例外を投げて、呼び出し元には 401 を返す
素直に書くと、こうなります。
@Transactional
public RotationResult rotate(String rawToken) {
RefreshToken stored = repository.findByTokenHash(sha256(rawToken))
.orElseThrow(() -> new InvalidRefreshTokenException("unknown refresh token"));
if (stored.getRevokedAt() != null) {
// 再利用を検知(盗用の疑い) → このユーザーの全トークンを失効させる
repository.revokeAllActiveByUserId(stored.getUserId(), OffsetDateTime.now());
throw new InvalidRefreshTokenException("refresh token reuse detected");
}
...
}
失効に使っているのは、一括更新のクエリです。
@Modifying
@Query("UPDATE RefreshToken r SET r.revokedAt = :now WHERE r.userId = :userId AND r.revokedAt IS NULL")
int revokeAllActiveByUserId(@Param("userId") Long userId, @Param("now") OffsetDateTime now);
起きたこと
テストはこう書きました。「盗用を検知したあとは、今まさに有効だったトークン(r1)も使えなくなっているはず」という確認です。
String t1 = refreshTokenService.issue(user.getId());
RotationResult r1 = refreshTokenService.rotate(t1); // t1 は失効し、r1 が有効
// 失効済み t1 の再提示=盗用 → 例外
assertThatThrownBy(() -> refreshTokenService.rotate(t1))
.isInstanceOf(InvalidRefreshTokenException.class);
// 全部失効しているので、r1 ももう使えないはず
assertThatThrownBy(() -> refreshTokenService.rotate(r1.newRawToken()))
.isInstanceOf(InvalidRefreshTokenException.class);
落ちたのは 2つ目の方でした。
RefreshTokenRotationTest > reuseOfRevokedTokenIsDetectedAndAllTokensRevoked() FAILED
java.lang.AssertionError:
Expecting code to raise a throwable.
「例外が飛ぶはずのところで、飛ばなかった」。
つまり r1 は失効しておらず、まだ普通に使えていたわけです。盗まれた側は締め出せていない。防御したつもりが、していませんでした。
最初は一括更新の WHERE 条件を疑って、revokedAt IS NULL の書き方を何度も読み返しました。クエリは合っていました。
原因
Spring の公式ドキュメントに、そのまま書いてあります。
In its default configuration, the Spring Framework's transaction infrastructure code marks a transaction for rollback only in the case of runtime, unchecked exceptions.
(既定では、実行時例外(unchecked)が投げられた場合にトランザクションをロールバック対象として印を付ける)
InvalidRefreshTokenException は RuntimeException を親に持つクラスです。
すると、こういう順番で処理が流れます。
-
revokeAllActiveByUserId(...)が UPDATE を発行する(まだコミットされていない) throw new InvalidRefreshTokenException(...)- Spring がこの実行時例外を見て、トランザクションをロールバックする
- 1 の UPDATE ごと巻き戻る
「防御 → 例外」の順に書いたのに、例外が防御を消していました。処理の順番は正しくて、コミットの単位が間違っていた、という話です。
対処
その例外のときはロールバックしない、と明示します。
@Transactional(noRollbackFor = InvalidRefreshTokenException.class)
public RotationResult rotate(String rawToken) { ... }
これでテストは緑になりました。
ただし noRollbackFor の意味は「全部コミットする」ではありません。正確には「指定した例外が飛んでも、その例外を理由にはロールバックしない」です。ほかにロールバック要因がなく、コミット自体も正常に終われば、同じトランザクション内の書き込みがまとめて残る、ということです。例外の種類に応じて一部の書き込みだけを選んで残す、という仕組みではありません。
つまり、同じトランザクションで他に何を書いているかを確かめてから付ける必要があります。そこで、例外を投げる3つの経路について、それまでに何を書き込んでいるかを実際にコードで追いました。
| 経路 | それまでに書き込んだもの |
|---|---|
| 未知のトークン | なし(検索して即 throw) |
| 期限切れ | なし(検索して即 throw) |
| 再利用検知 | 全トークン失効(=残したいもの) |
書き込みがあるのは再利用検知の経路だけでした。このメソッドは監査ログなど他のテーブルを触っておらず、エンティティのライフサイクルコールバックや楽観ロック(@Version)も無いので、同じトランザクションに紛れ込む書き込みは他にありません。残るのは「残したかった失効」だけ。だから noRollbackFor で問題ない、と判断しました。
別のやり方
トランザクションを分ける手もあります。失効処理だけを Propagation.REQUIRES_NEW の別メソッドに切り出せば、そこは外側とは別のトランザクションになり、内側が正常にコミットしたあとなら外側がロールバックしても失効は残ります。「守りたい書き込みだけ別トランザクションにする」という考え方で、意図が読み取りやすい場面もあります。
ただし注意点が2つあります。
1つ目。REQUIRES_NEW は外側とは別の DB 接続を要求します。公式は、これが接続プールの枯渇やデッドロックにつながりうるとして、こう釘を刺しています。
Do not use
PROPAGATION_REQUIRES_NEWunless your connection pool is appropriately sized, exceeding the number of concurrent threads by at least 1.
2つ目。別メソッドに切り出すとき、同じクラスの中で自分のメソッドを呼んでも効きません。既定のプロキシ方式では、外から入ってくる呼び出ししか差し込まれないためです。
only external method calls coming in through the proxy are intercepted. This means that self-invocation ... does not lead to an actual transaction at runtime even if the invoked method is marked with
@Transactional.
なので、切り出すなら別の Bean に置いてそれ経由で呼ぶのが素直です(自己呼び出しも効かせたいなら AspectJ 方式という選択肢もあります)。ここは私も一度勘違いしていた場所です。
今回は経路が3本しかなく、書き込みが1か所に閉じていたので、接続を増やす REQUIRES_NEW ではなく、設定を1つ変えるだけで済む noRollbackFor を選びました。
テストで「効いていること」を確かめる
理屈だけだと不安だったので、noRollbackFor を外して同じテストを走らせ直しました。
./gradlew test --tests 'com.example.app.auth.RefreshTokenRotationTest'
外すと、例外のあとに全トークン失効がロールバックされ、盗まれた側のトークンがまだ使えてしまうことをテストが検知します(先ほどの Expecting code to raise a throwable.)。設定を戻すと、例外のあともすべて失効した状態が保たれることを確認できました。「失敗するはずのケースがちゃんと失敗する」ところまで見て、やっと納得できました。
補足:一括更新と永続化コンテキスト
@Modifying を付けた JPQL の UPDATE は、管理対象エンティティを1件ずつ更新する処理を経由せず、更新クエリとして直接発行されます。ですから、同じトランザクションで先に読み込んでいたエンティティは、DB を更新しても古い状態のまま残ります(DB の値とメモリ上の値が食い違う)。
今回は更新の直後に例外を投げて処理を抜けるので実害はありませんが、一括更新のあとに同じエンティティを読み書きするコードでは注意が必要です。必要なら @Modifying(clearAutomatically = true) で更新後に自動クリアできます(ただし未フラッシュの変更も破棄されます)。
学び
-
@Transactionalの既定では、実行時例外(RuntimeException系)はロールバックを引き起こす。「失敗を記録する」「防御する」処理を同じトランザクションの中で書いて、そのあと例外を投げると消える -
noRollbackForは「その例外を理由にはロールバックしない」設定。ほかにロールバック要因がなければ書き込みは残る。だから、同じトランザクション内で何を書いているかを事前に確認しておくことが大切 - 書き込みを確実に残したいなら
REQUIRES_NEWで分ける手もある。ただし別接続を使うのでプールサイズに注意、かつ自己呼び出しでは効かない - 「テストが落ちてから」ではなく「設定を外して落ちることを確かめる」と、対策が本当に効いているか自分の目で確認できる
おわりに
セキュリティのための処理ほど、「書いた=効いている」と思い込みがちでした。効いているかどうかは、結局テストが教えてくれます😅
同じところで止まった方の助けになれば嬉しいです🙌
参考
(この記事は Spring Framework 6.2 系・Spring Data JPA 3.5 系で検証しました。内容が変わらないよう、リンクはそのバージョンに固定しています)