Spring Security + JWTでステートレス認証を実装する方法


REST APIを開発していると、 セッションを使わずにリクエストごとに認証を完結させたい という場面が必ず来ます。フォーム認証やBasic認証でSpring Securityに慣れてきた方の、次のステップが JWT(JSON Web Token)によるステートレス認証 です。

Spring SecurityでBasic認証・フォーム認証を実装する方法 では認証の基本を解説しました。本記事ではその続きとして、REST API向けに JWT発行・検証・Filterへの組み込み をゼロから実装します。

読み終えると、次のことが独力でできるようになります。

  • /api/auth/login にPOSTしてJWTを受け取る
  • 取得したトークンをAuthorizationヘッダーに付与して保護エンドポイントにアクセスする
  • curl / Postmanでエンドツーエンドの動作を確認する

実装の全体像は次の流れです。

依存関係追加 → JwtUtil → ログインエンドポイント → JwtAuthenticationFilter → SecurityFilterChain設定

順番に進めましょう。


JWTとステートレス認証の仕組みを3分で理解する

JWTの3パート構成

JWT(JSON Web Token)は ドット(.)で区切られた3つのパート から構成されます。

header.payload.signature
パート内容エンコード
Headerアルゴリズム種別(例:HS256)Base64URL
Payloadクレーム(ユーザー名・有効期限など)Base64URL
SignatureHeaderとPayloadをまとめて秘密鍵で署名したものバイナリのBase64URL

HeaderとPayloadは Base64URLエンコードされているだけ で暗号化はされていません。機密情報(パスワードなど)はPayloadに含めないようにしましょう。Signatureが改ざん検知の要です。

ステートレス認証のフロー

クライアント                         サーバー
   |                                    |
   |  POST /api/auth/login              |
   |  { username, password }  -------> |
   |                                    |  認証成功 → JWT生成
   |  { token: "eyJ..." }   <--------- |
   |                                    |
   |  GET /api/protected               |
   |  Authorization: Bearer eyJ... --> |
   |                                    |  JWTを検証 → SecurityContextにセット
   |  200 OK                <--------- |

ポイントは サーバー側にセッションを保持しない ことです。トークン自体に認証情報が含まれるため、複数のサーバーインスタンスに水平スケールしやすくなります。フォーム認証・Basic認証との根本的な違いはここにあります。

本記事のスコープ外: OAuth2/OIDC連携、リフレッシュトークンの詳細実装、マイクロサービス間のJWT伝播は扱いません。


依存関係の追加(Maven / Gradle)

本記事では jjwt(Java JWT)ライブラリを使います。jjwt 0.11以降はアーティファクトが3つに分かれています。

アーティファクト役割
jjwt-apiAPIインターフェース
jjwt-impl実装(ランタイムのみ必要)
jjwt-jacksonJacksonによるJSON解析

Maven(pom.xml)

<dependencies>
    <!-- Spring Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- jjwt -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.6</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Gradle(build.gradle)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}

注意: 古い jjwt 0.9.x 系はAPIが大きく異なります。ネット上の記事で Jwts.parser().setSigningKey(...) という書き方を見かけたら 0.9.x 系のコードです。本記事は 0.11以降の新API を使います。バージョンは Maven Central で最新版を確認してください。


application.properties:JWT秘密鍵・有効期限の外部化

まず設定値を外部化しておきます。秘密鍵は 本番環境では環境変数やVaultで管理 し、application.propertiesに直書きしないようにしましょう(詳細は後述)。

# JWT設定
# jwt.secretは32文字(256ビット)以上のランダムな文字列を設定してください
# 生成例: openssl rand -hex 32
jwt.secret=please-change-this-secret-key-in-production-environment-32chars
jwt.expiration=86400000
# 86400000ms = 24時間

単位に注意: jwt.expirationミリ秒 で指定します。秒と間違えると1000倍短いトークンが発行されます(よくある罠です)。


JwtUtil:トークン生成・クレーム抽出・有効期限検証

JWT操作をすべて一か所にまとめたユーティリティクラスを実装します。

package com.example.demo.security;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.function.Function;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    // 秘密鍵を生成(application.propertiesの値をUTF-8バイト列としてHMAC-SHA256鍵に変換)
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    // トークン生成
    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
                .subject(userDetails.getUsername())
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey())
                .compact();
    }

    // ユーザー名の抽出
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // 有効期限の抽出
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // 汎用クレーム抽出
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        // 署名検証を行いながらClaimsを取得する。不正なトークンはここで例外になる
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    // 有効期限チェック
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    // トークン検証(署名検証 + 有効期限チェック + ユーザー名一致確認)
    // 注意: extractAllClaims内でExpiredJwtException等がスローされる場合があります。
    //       呼び出し元のJwtAuthenticationFilterでtry-catchしてください。
    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }
}

例外の扱い方針:

例外意味対処
ExpiredJwtException有効期限切れ401を返す
MalformedJwtExceptionトークン形式不正401を返す
SignatureException署名不正(改ざん)401を返す
UnsupportedJwtException非対応のJWT形式401を返す

これらの例外は後述の JwtAuthenticationFilter でキャッチします。


UserDetailsServiceの実装

Spring SecurityがユーザーをロードするためのServiceを用意します。

注意: 以下のインメモリ実装(UserDetailsConfig)とDB連携実装(UserDetailsServiceImpl)は どちらか一方のみ プロジェクトに定義してください。両方定義すると UserDetailsService のBean定義が競合してアプリが起動しません。

インメモリからDB連携に移行する手順:

  1. UserDetailsConfig クラスの @Configuration アノテーションを削除するか、クラスごと削除する
  2. UserDetailsServiceImpl@Service アノテーションを有効にする(下記コードでは既に付与済み)
  3. PasswordEncoder@Bean 定義を SecurityConfig 等に移動する

インメモリ実装(サンプル・検証用)

package com.example.demo.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class UserDetailsConfig {

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        return new InMemoryUserDetailsManager(
            User.builder()
                .username("user")
                .password(passwordEncoder.encode("password"))
                .roles("USER")
                .build()
        );
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

DB連携への拡張(実用パターン)

本番環境ではDBからユーザーをロードします。インターフェースの実装は以下のパターンが基本です。

package com.example.demo.security;

import com.example.demo.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByUsername(username)
                .map(user -> User.builder()
                        .username(user.getUsername())
                        .password(user.getPassword())  // BCryptでエンコード済みの値
                        .roles(user.getRole())
                        .build()
                )
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
    }
}

getPassword()BCryptでエンコード済みのハッシュ値 を返す必要があります。平文パスワードをそのまま返すと認証が常に失敗するので注意してください。


LoginRequest / AuthenticationResponse DTOクラス

package com.example.demo.auth;

import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
    @NotBlank String username,
    @NotBlank String password
) {}
package com.example.demo.auth;

public record AuthenticationResponse(String token) {}

バリデーションアノテーションの詳細は Spring Bootで@Validを使ってバリデーションする方法 を参照してください。


/api/auth/login エンドポイントの実装

package com.example.demo.auth;

import com.example.demo.security.JwtUtil;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.*;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {

    private final AuthenticationManager authenticationManager;
    private final UserDetailsService userDetailsService;
    private final JwtUtil jwtUtil;

    @PostMapping("/login")
    public ResponseEntity<AuthenticationResponse> login(@Valid @RequestBody LoginRequest request) {
        try {
            // Spring Securityに認証を委譲(失敗時はBadCredentialsExceptionがスロー)
            authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.username(), request.password())
            );
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(401).build();
        }

        // 認証成功 → JWTを生成して返す
        UserDetails userDetails = userDetailsService.loadUserByUsername(request.username());
        String token = jwtUtil.generateToken(userDetails);
        return ResponseEntity.ok(new AuthenticationResponse(token));
    }
}

ポイント: AuthenticationManager.authenticate() はSpring Securityの認証メカニズム全体を呼び出します。パスワードのBCrypt検証やUserDetailsServiceの呼び出しを自動で行ってくれるため、自前で比較処理を書く必要はありません。

エラーハンドリングの設計については Spring BootのREST APIで例外処理を統一する方法 も参考にしてください。


JwtAuthenticationFilter の実装(OncePerRequestFilter)

すべてのリクエストで 1度だけ 実行されるFilterを実装します。これがJWT認証の中核です。

注意: このクラスには @Component を付けないでください。@Component を付けるとSpring Bootがサーブレットフィルターとして自動登録してしまい、SecurityFilterChain への addFilterBefore 登録と合わせてフィルターが 二重登録 されます。SecurityConfig 内で @Bean として定義することで、セキュリティチェーン専用のフィルターとして正しく機能します。

package com.example.demo.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

        final String authHeader = request.getHeader("Authorization");

        // Authorizationヘッダーが存在しない、またはBearerトークンでない場合はスキップ
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String token = authHeader.substring(7); // "Bearer "の7文字を除去
        final String username;

        try {
            username = jwtUtil.extractUsername(token);
        } catch (Exception e) {
            // トークン形式不正・署名エラーなど → 401を返してFilterChainを止める
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
            return;
        }

        // ユーザー名が取得でき、かつまだ認証されていない場合のみ処理を続ける
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (jwtUtil.validateToken(token, userDetails)) {
                // 認証オブジェクトを生成してSecurityContextにセット
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                    );
                authToken.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request)
                );
                SecurityContextHolder.getContext().setAuthentication(authToken);
            } else {
                // 有効期限切れなど検証失敗
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT token expired or invalid");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }
}

OncePerRequestFilter を使う理由: サーブレットのFilterはリクエストの転送(forward)などで複数回呼ばれる場合があります。OncePerRequestFilter はリクエストごとに 確実に1回だけ 実行されることを保証します。


SecurityFilterChainの設定

package com.example.demo.security;

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    // JwtAuthenticationFilterをBeanとして定義(@Componentは付けない)
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtUtil, userDetailsService);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // ステートレスAPIではCSRFトークンは不要(セッションを使わないため)
            .csrf(csrf -> csrf.disable())

            // セッションを一切生成・使用しない
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )

            // 未認証リクエストに対して401を返す
            .exceptionHandling(e -> e.authenticationEntryPoint(
                (req, res, ex) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
            ))

            // エンドポイントごとのアクセス制御
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()  // ログインは認証不要
                .anyRequest().authenticated()                  // それ以外はすべて認証必須
            )

            // JwtAuthenticationFilterをUsernamePasswordAuthenticationFilterの前に挿入
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    // AuthenticationManagerをBeanとして公開(AuthenticationControllerで使用)
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config
    ) throws Exception {
        return config.getAuthenticationManager();
    }
}

addFilterBefore の位置について: UsernamePasswordAuthenticationFilter前に 挿入することで、Spring Securityがフォーム認証を試みる前にJWT検証が走ります。JWTが有効であればSecurityContextに認証情報がセットされるため、以降のFilterは認証済みとして扱います。

csrf().disable() の根拠: CSRFは主に ** ブラウザのセッションCookie ** を悪用した攻撃です。ステートレスなJWT認証ではCookieではなくAuthorizationヘッダーでトークンを送るため、CSRF攻撃が成立しません。ただし ** HTTPS化は必須** です(後述)。


動作確認:curl / Postman でエンドツーエンドテスト

アプリを起動したら、以下の手順で動作を確認しましょう。

1. ログインしてJWTを取得

curl -s -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "user", "password": "password"}'

レスポンス例:

{
  "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNzA2MDAwMDAwLCJleHAiOjE3MDYwODY0MDB9.xxxxx"
}

2. 取得したトークンで保護エンドポイントにアクセス

# TOKEN変数にレスポンスのtokenをセット
TOKEN="eyJhbGciOiJIUzI1NiJ9..."

curl -s http://localhost:8080/api/hello \
  -H "Authorization: Bearer $TOKEN"

3. 異常系の確認

Spring Security は未認証リクエストに対して 401 Unauthorized を返します。exceptionHandlingauthenticationEntryPoint で明示的に設定しているため、以下のいずれのケースでも 401 が返ります。

# トークンなし → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello
# 出力例: 401

# 改ざんトークン → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello \
  -H "Authorization: Bearer invalidtoken"
# 出力例: 401

# 期限切れトークン → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello \
  -H "Authorization: Bearer <expired-token>"
# 出力例: 401

Postmanでの効率的なテスト

Postmanの Environment Variablestoken 変数を定義し、ログインリクエストの Tests タブに以下を設定すると、以降のリクエストに自動でトークンが付与されます。

const response = pm.response.json();
pm.environment.set("token", response.token);

その後のリクエストのAuthorizationタブで Bearer {{token}} と指定するだけです。


よくあるエラーと対処法

403 Forbiddenが返る(トークンを送っているのに)

  • SecurityFilterChainrequestMatchers 設定が正しいか確認する
  • SecurityConfig 内に @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() が定義されているか確認する
  • addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) が設定されているか確認する

401 Unauthorized(トークンを送っているのに)

  • SecurityContextHolder.getContext().setAuthentication() が実際に呼ばれているかデバッグログで確認する
  • application.propertiesjwt.secret32文字(256ビット)以上のランダムな文字列 になっているか確認する
  • トークンを発行したサーバーと検証するサーバーで 秘密鍵が同一 かどうか確認する

ExpiredJwtException が頻発する

  • jwt.expiration の単位を確認する。ミリ秒 で指定する必要があります。86400 と書くと86.4秒で切れます(正しくは 86400000

SignatureException:署名検証に失敗する

  • 環境ごとに jwt.secret の値が異なっていないか確認する(開発・ステージング・本番で同じトークンを使い回している場合など)
  • jwt.secret に十分な長さ(32文字以上)の文字列が設定されているか確認する

NullPointerException in doFilterInternal

  • Authorization ヘッダーが存在しないリクエストに対するnullチェックを忘れていないか確認する。本記事のFilterでは authHeader == null のチェックを最初に行っています

よくある質問

JWTとセッション認証はどう違いますか?REST APIにJWTが向いている理由は?

セッション認証はサーバー側にセッションストアが必要で、複数インスタンスへのスケールアウト時にセッション共有(Redis等)が必要になります。JWTはトークン自体に認証情報を含むため サーバー側にステートが不要 で、スケールアウトが容易です。REST APIは本質的にステートレスな設計が好ましいため、JWTとの相性が良いです。

jjwtとjava-jwt(Auth0)どちらを使うべきですか?

どちらも広く使われていますが、Spring Bootコミュニティでの採用例が多く、APIが直感的な jjwt を本記事では採用しています。機能的にはほぼ同等です。プロジェクトで既にどちらかを使っているなら統一するのがよいでしょう。

秘密鍵はどこに保存すれば安全ですか?

application.properties への直書きは 開発環境のみ にしてください。本番環境では 環境変数JWT_SECRET などで注入)または HashiCorp Vault などのシークレット管理ツールを使用することを強く推奨します。

トークンの有効期限はどのくらいに設定すべきですか?

一般的には アクセストークンは15分〜1時間 が推奨されます。本記事のサンプルは24時間ですが、これは検証用の設定です。有効期限が長いほどトークン漏洩時のリスクが高まります。短命なアクセストークンと長命なリフレッシュトークンを組み合わせる設計が実用的です。

Spring Boot 3.x(Spring Security 6)でSecurityFilterChainの書き方が変わりましたか?

はい。Spring Security 5系では http.csrf().disable() のようなメソッドチェーン記法が使われていましたが、Spring Security 6では ラムダDSL形式csrf(csrf -> csrf.disable()) 形式)が標準になりました。本記事のコードは Spring Boot 3.x / Spring Security 6対応の書き方 で統一しています。

JwtAuthenticationFilterでaddFilterBeforeに追加しているのにJWTが検証されない場合は何が原因ですか?

最も多い原因は @Component による二重登録です。@Component を付けるとSpring Bootがサーブレットフィルターとして自動登録し、セキュリティチェーン外で先に実行されてしまいます。本記事のように @Component を除去して SecurityConfig 内で @Bean として定義することで解消します。

ログアウト機能はどう実装しますか?JWTをサーバー側で無効化できますか?

JWT自体はステートレスなため、発行済みトークンをサーバー側から無効化するには ブラックリスト(無効化トークンの一覧をRedis等に保存) するアプローチが一般的です。ただし、これはステートレスの利点を一部失うトレードオフを伴います。有効期限を短く設定してリフレッシュトークンと組み合わせる方が多くのケースで現実的です。


次のステップ:リフレッシュトークンとセキュリティ強化

ここまでの実装で、JWT認証の基本的な仕組みは完成しました。実用的なシステムに向けた次のステップを紹介します。

リフレッシュトークン

アクセストークンの有効期限を短く設定(15〜60分)しつつ、リフレッシュトークン(数日〜数週間有効)を使ってアクセストークンを再発行する仕組みです。漏洩時のリスクを限定できますが、リフレッシュトークン自体の管理(DBへの保存・無効化)が必要になります。詳細実装は別記事で解説予定です。

トークンブラックリスト(ログアウト処理)

ログアウト時に無効化したいトークンのJTI(JWT ID)をRedisに保存し、Filter内でブラックリスト確認を行うアプローチです。ステートレスの利点を一部犠牲にしますが、即時無効化が必要なケースでは有効です。

HTTPS必須

JWTはAuthorizationヘッダーで送信されるため、平文HTTP通信では盗聴リスク があります。本番環境では必ずHTTPSを使用してください。ローカル開発以外でHTTPを使うことは避けましょう。

本番環境での秘密鍵管理

前述の通り、application.properties への秘密鍵直書きは避け、環境変数 または HashiCorp Vault などのシークレット管理ツールを使用してください。CI/CDパイプラインでの秘密鍵の扱いにも注意が必要です。


まとめ

本記事では、Spring Boot REST APIにJWT認証を組み込む手順を解説しました。

ステップ実装内容
1jjwtの依存関係追加
2application.propertiesに秘密鍵・有効期限を外部化
3JwtUtil でトークン生成・検証ロジックを実装
4UserDetailsService でユーザーロードの土台を整備
5/api/auth/login でJWT発行エンドポイントを実装
6JwtAuthenticationFilter でリクエストごとのトークン検証を実装
7SecurityFilterChain でSTATELESS設定・Filter挿入・認可ルールを設定
8curl / Postmanでエンドツーエンドテストを実施

Basic認証・フォーム認証からJWT認証へのステップアップは、REST APIの設計として自然な進化です。ぜひ自分のプロジェクトに組み込んでみてください。