JWT認証を実装したものの、アクセストークンの有効期限を短くすると再ログインが頻発し、長くすると盗難リスクが上がる。よくあるジレンマですよね。

この問題はリフレッシュトークンで解決できます。本記事では、Spring Boot と Redis を組み合わせて、ローテーション・リユース検知・失効までを含めた実装パターンを紹介します。アクセストークン発行までは Spring SecurityでJWT認証を実装する方法 でカバー済みの前提で進めます。

2つのトークンを分ける理由

アクセストークンとリフレッシュトークンは、責務がはっきり違います。

  • アクセストークンは、API呼び出しに毎回付与するもの。短命(5〜15分)でステートレス検証。
  • リフレッシュトークンは、アクセストークンの再発行だけに使うもの。長命(数日〜数週間)でサーバ側に状態を持つ。

アクセストークンを短命にすることで、万が一漏れても被害時間を限定できます。一方、リフレッシュトークンはサーバ側のRedisに保存しておくので、いつでも失効させられる。この役割分担が肝です。

保存先は Redis か RDB か

本記事では Redis を採用します。理由はシンプルで、TTL(自動失効)が標準機能としてあり、高速で、スケールアウトしやすいからです。

RDBにも利点はあって、既存インフラを流用しやすく、トランザクション一貫性や監査ログ向きです。とはいえ、リフレッシュトークンの読み書きはユーザー操作のたびに走るので、Redisの方が運用上は楽。Redis導入は Spring BootとRedisの統合ガイド を参考にしてください。

なお、保存するのは平文ではなく ハッシュ値 にします。Redisが万が一漏れてもトークンそのものは再構成できないようにするためです。

依存関係の準備

Lombokの @RequiredArgsConstructor を本文中で使うので、依存に含めておきます。HS256で署名する場合、秘密鍵は最低256ビット(32バイト)以上が必要で、これを下回ると Keys.hmacShaKeyFor が例外を投げます。環境変数の JWT_SECRET は32文字以上のランダム値を設定してください。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

有効期限は外部化しておくと環境ごとに調整できて便利です。

app:
  jwt:
    secret: ${JWT_SECRET}
    access-token-ttl: PT15M
    refresh-token-ttl: P14D
spring:
  data:
    redis:
      host: localhost
      port: 6379

JwtTokenProvider

トークン生成と検証の中心です。jti (JWT ID)を必ず含めておくのがポイントで、後で失効管理するときの識別子になります。type クレームでアクセス用とリフレッシュ用を区別するのも忘れずに。

@Component
public class JwtTokenProvider {
    private final SecretKey key;
    private final Duration accessTtl;
    private final Duration refreshTtl;

    public JwtTokenProvider(@Value("${app.jwt.secret}") String secret,
                            @Value("${app.jwt.access-token-ttl}") Duration accessTtl,
                            @Value("${app.jwt.refresh-token-ttl}") Duration refreshTtl) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessTtl = accessTtl;
        this.refreshTtl = refreshTtl;
    }

    public String createAccess(String userId) {
        return build(userId, UUID.randomUUID().toString(), "access", accessTtl);
    }

    public String createRefresh(String userId, String jti) {
        return build(userId, jti, "refresh", refreshTtl);
    }

    public Claims parse(String token) {
        return Jwts.parser().verifyWith(key).build()
                .parseSignedClaims(token).getPayload();
    }

    private String build(String userId, String jti, String type, Duration ttl) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(userId).id(jti)
                .claim("type", type)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plus(ttl)))
                .signWith(key).compact();
    }
}

ハッシュ化用のユーティリティも一緒に用意しておきます。Redisに平文を保存しないために、保存・検証の両方でこれを通します。

public static String sha256(String input) {
    try {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(hash);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException(e);
    }
}

RefreshTokenStore

Redisにリフレッシュトークンのハッシュを保存します。キー設計は refresh:{userId}:{jti} の形にすると、ユーザー単位の一括削除がやりやすくなります。

リユース検知のために、ローテーションした旧 jti を即削除するのではなく、別キー used:{userId}:{jti} に「使用済みマーク」として残します。次回同じ jti が提示されたら盗難の可能性があるとみなす設計です。

@Component
@RequiredArgsConstructor
public class RefreshTokenStore {
    private final StringRedisTemplate redis;

    public void save(String userId, String jti, String tokenHash, Duration ttl) {
        redis.opsForValue().set(activeKey(userId, jti), tokenHash, ttl);
    }

    public boolean isActive(String userId, String jti, String tokenHash) {
        String stored = redis.opsForValue().get(activeKey(userId, jti));
        return tokenHash.equals(stored);
    }

    public boolean isUsed(String userId, String jti) {
        return Boolean.TRUE.equals(redis.hasKey(usedKey(userId, jti)));
    }

    public void markUsed(String userId, String jti, Duration ttl) {
        redis.delete(activeKey(userId, jti));
        redis.opsForValue().set(usedKey(userId, jti), "1", ttl);
    }

    public void revokeAll(String userId) {
        String pattern = "refresh:" + userId + ":*";
        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build();
        try (Cursor<String> cursor = redis.scan(options)) {
            cursor.forEachRemaining(redis::delete);
        }
        // used:{userId}:* も同様にSCANで掃除する想定
    }

    private String activeKey(String userId, String jti) {
        return "refresh:" + userId + ":" + jti;
    }

    private String usedKey(String userId, String jti) {
        return "used:" + userId + ":" + jti;
    }
}

本番では KEYS コマンドはRedisをブロックするため、ここでは SCAN を使っています。盗難検知時の revokeAll は頻度こそ低いものの、ブロッキング操作はサービス全体に影響するので避けるのが安全です。

/auth/refresh の実装

ここがリフレッシュトークンの一番大事な部分です。検証 → 新トークン発行 → 旧トークンを使用済みマーク、をワンセットで行います。

リクエスト・レスポンスは record で簡潔に定義しておきます。

public record RefreshRequest(String refreshToken) {}
public record TokenResponse(String accessToken, String refreshToken) {}
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final JwtTokenProvider tokens;
    private final RefreshTokenStore store;
    private static final Duration REFRESH_TTL = Duration.ofDays(14);

    @PostMapping("/refresh")
    public TokenResponse refresh(@RequestBody RefreshRequest req) {
        Claims claims = tokens.parse(req.refreshToken());
        if (!"refresh".equals(claims.get("type", String.class))) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "wrong token type");
        }
        String userId = claims.getSubject();
        String oldJti = claims.getId();
        String hash = sha256(req.refreshToken());

        if (store.isUsed(userId, oldJti)) {
            store.revokeAll(userId);
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "reuse detected");
        }
        if (!store.isActive(userId, oldJti, hash)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid token");
        }

        store.markUsed(userId, oldJti, REFRESH_TTL);
        String newJti = UUID.randomUUID().toString();
        String newRefresh = tokens.createRefresh(userId, newJti);
        store.save(userId, newJti, sha256(newRefresh), REFRESH_TTL);
        return new TokenResponse(tokens.createAccess(userId), newRefresh);
    }
}

ポイントは2つです。1つ目は type クレームの検証で、アクセストークンを誤ってリフレッシュ用に転用される脆弱性を防ぎます。2つ目は使用済みマークの確認で、署名が正しくても「過去にローテーションした旧 jti 」が再提示されたら盗難として revokeAll を走らせます。

なお、削除と保存をアトミックに行いたい場合は RedisTemplate#execute でLuaスクリプトを使う方法があります。今回は使用済みマーク方式のため、競合が起きてもどちらかが「使用済み」を踏んで安全側に倒れる設計になっています。

アクセストークンのブラックリスト

リフレッシュトークンはストアから消せば即失効しますが、アクセストークンはステートレス検証なので、残存期間中は有効なままです。

ログアウトやパスワード変更で即時失効したい場合は、jti をブラックリストに登録します。TTLはアクセストークンの残り有効期限と同じにしておけば、自動的に消えるので運用が楽です。

public void blacklist(String jti, Duration remaining) {
    redis.opsForValue().set("bl:" + jti, "1", remaining);
}

public boolean isBlacklisted(String jti) {
    return Boolean.TRUE.equals(redis.hasKey("bl:" + jti));
}

SecurityFilterChain と認証フィルタ

セッションは STATELESS 、本記事はAuthorizationヘッダのBearerトークン前提なのでCSRFは無効化します(Cookieにリフレッシュトークンを格納する設計に切り替える場合は、 /auth/** 以外でCSRFを有効化し、SameSite Cookie + CSRFトークン併用が必要です)。

@Bean
SecurityFilterChain chain(HttpSecurity http, JwtAuthenticationFilter jwt) throws Exception {
    return http
        .csrf(c -> c.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(a -> a
            .requestMatchers("/auth/**").permitAll()
            .anyRequest().authenticated())
        .addFilterBefore(jwt, UsernamePasswordAuthenticationFilter.class)
        .build();
}

認証フィルタ内では、署名検証に加えてブラックリスト確認まで行います。

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider tokens;
    private final TokenBlacklist blacklist;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String header = req.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            try {
                Claims claims = tokens.parse(header.substring(7));
                if ("access".equals(claims.get("type", String.class))
                        && !blacklist.isBlacklisted(claims.getId())) {
                    Authentication auth = new UsernamePasswordAuthenticationToken(
                            claims.getSubject(), null, List.of());
                    SecurityContextHolder.getContext().setAuthentication(auth);
                }
            } catch (JwtException ignored) {
                // 失敗時は未認証のまま進める
            }
        }
        chain.doFilter(req, res);
    }
}

メソッド単位の認可を加えたい場合は @PreAuthorizeでメソッドセキュリティを実装する方法 も合わせて読むと理解が深まります。

クライアント側の保存場所

SPAの場合、リフレッシュトークンは HttpOnly + Secure + SameSite Cookieに保存するのが第一選択です。localStorageに置くとXSSで簡単に盗まれます。

Cookieを使うときはCSRF対策として SameSite=Lax または Strict を必ず設定し、リフレッシュエンドポイントは別パスに分けてCSRFトークンを併用するのが安全です。モバイルアプリの場合はOS側のセキュアストレージ(iOSのKeychain、AndroidのEncryptedSharedPreferences)を使います。

動作確認

# ログイン
curl -X POST localhost:8080/auth/login -d '{"id":"u1","pw":"..."}'

# リフレッシュ(正常系)
curl -X POST localhost:8080/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refreshToken":"<token>"}'

# 同じトークンを2回目に送るとリユース検知で401、かつ当該ユーザーの全リフレッシュトークンが失効

MockMvcでは最低限、次の3パターンを押さえておくと安心です。

@Test
void refresh_success_rotatesToken() throws Exception {
    mockMvc.perform(post("/auth/refresh").contentType(APPLICATION_JSON)
            .content("{\"refreshToken\":\"" + validToken + "\"}"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.refreshToken").value(not(validToken)));
}

@Test
void refresh_reuse_revokesAllSessions() throws Exception {
    // 1回目は成功
    mockMvc.perform(post("/auth/refresh").contentType(APPLICATION_JSON)
            .content("{\"refreshToken\":\"" + validToken + "\"}"));
    // 2回目は同じトークン → 401 + revokeAll
    mockMvc.perform(post("/auth/refresh").contentType(APPLICATION_JSON)
            .content("{\"refreshToken\":\"" + validToken + "\"}"))
        .andExpect(status().isUnauthorized());
}

まとめ

リフレッシュトークンの実装で押さえるべきは、ローテーション・リユース検知・ハッシュ化保存の3点です。RedisのTTLを活かせば失効管理もシンプルに収まります。

本番投入前に、クロックスキューのマージン(数十秒程度)や、短時間に複数リクエストが走った際のリフレッシュ競合も検証しておきましょう。クライアント側で401時のリトライ実装を用意するか、旧トークンに数秒のgrace periodを設けるなど、UXとのバランスをとる設計が現実的です。