Spring SecurityでJWT認証を実装するには、oauth2ResourceServer の設定に JwtDecoder(JWTを検証する部品)を1つ渡します。 署名の検証、有効期限の確認、権限への変換、401の応答までをSpring Securityが受け持つので、自分で書くのは「どの鍵で検証するか」「どのクレームを権限にするか」「どのパスに何を求めるか」の3点です。

この記事では、Spring Boot 4.1/Spring Security 7.1 向けの設定コード全文(共通鍵と公開鍵の2系統)、設定項目の一覧、401と403がどこで決まるかの判断表、テストの書き方、Boot 3から4で変わった点をまとめました。JWTそのものの動きは、Java 21だけで動く小さなコードで実際に作って検証しています。

確認環境:Spring Security 7.1.1・Spring Boot 4.1.1 の公式リファレンスとAPIドキュメント(確認日 2026年10月7日)で確認。JWTの作成と検証は Ubuntu 24.04・OpenJDK 21.0.10 で実行。Springの設定コードは公式ドキュメントに準拠して書いています。

JWT認証の実装の早見表

決めること 書く場所 使うもの
依存関係 pom.xml spring-boot-starter-security-oauth2-resource-server
JWT認証を有効にする SecurityFilterChain oauth2ResourceServer((oauth2) -> oauth2.jwt(...))
共通鍵(HS256)で検証 JwtDecoder のBean NimbusJwtDecoder.withSecretKey(key)
公開鍵(RS256)で検証 JwtDecoder のBeanか設定 NimbusJwtDecoder.withPublicKey(key)/public-key-location
認可サーバーの鍵で検証 application.yml issuer-uri/jwk-set-uri
クレームを権限にする JwtAuthenticationConverter setAuthoritiesClaimName・setAuthorityPrefix
期限・発行者・audの検証 setJwtValidator JwtTimestampValidator など
パスごとの認可 authorizeHttpRequests hasRole・hasScope・authenticated
JWTを発行する JwtEncoder のBean NimbusJwtEncoder.withSecretKey(key)
テスト MockMvc jwt()

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Security JWT認証の実装|Boot 4対応の設定コード全文」

JWTの作成と検証をJava 21だけで確かめる

Springの設定に入る前に、JWTが「ヘッダー.ペイロード.署名」の3つの部分でできていて、署名と期限をどう確かめるかを、標準ライブラリだけのコードで見ておきます。JwtDecoder が内部でしていることの最小版です。

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/** HS256 の JWT を作って検証する(Java 21 標準ライブラリのみ) */
public class JwtHs256 {
    static final Base64.Encoder ENC = Base64.getUrlEncoder().withoutPadding();
    static final Base64.Decoder DEC = Base64.getUrlDecoder();

    static String b64(String json) {
        return ENC.encodeToString(json.getBytes(StandardCharsets.UTF_8));
    }

    static byte[] hmac(byte[] key, String data) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key, "HmacSHA256"));
        return mac.doFinal(data.getBytes(StandardCharsets.US_ASCII));
    }

    /** ヘッダー.ペイロード.署名 を組み立てる */
    static String sign(byte[] key, String payloadJson) throws Exception {
        String header = b64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
        String body = b64(payloadJson);
        String sig = ENC.encodeToString(hmac(key, header + "." + body));
        return header + "." + body + "." + sig;
    }

    /** 署名 → 有効期限(ずれ許容つき)の順に確かめ、結果を文字列で返す */
    static String verify(byte[] key, String token, long nowEpoch, long skewSeconds) throws Exception {
        String[] p = token.split("\\.");
        if (p.length != 3) return "NG: 3つの部分に分かれていません";
        byte[] expected = hmac(key, p[0] + "." + p[1]);
        byte[] actual = DEC.decode(p[2]);
        if (!MessageDigest.isEqual(expected, actual)) return "NG: 署名が一致しません";
        String payload = new String(DEC.decode(p[1]), StandardCharsets.UTF_8);
        long exp = Long.parseLong(payload.replaceAll(".*\"exp\":(\\d+).*", "$1"));
        if (nowEpoch > exp + skewSeconds) return "NG: 有効期限切れ (exp=" + exp + ", now=" + nowEpoch + ")";
        return "OK: " + payload;
    }

    public static void main(String[] args) throws Exception {
        // HS256 の鍵は 256ビット(32バイト)以上にする。ここでは説明用の固定値
        byte[] key = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
        long now = Instant.parse("2026-10-07T00:00:00Z").getEpochSecond();
        String payload = "{\"iss\":\"https://issuer.example.com\",\"sub\":\"user-1\","
                + "\"aud\":\"task-api\",\"scope\":\"task:read task:write\","
                + "\"iat\":" + now + ",\"exp\":" + (now + 900) + "}";

        String token = sign(key, payload);
        System.out.println("token  = " + token);
        System.out.println("長さ    = " + token.length() + " 文字");

        System.out.println("1) 正しい鍵・期限内      -> " + verify(key, token, now + 60, 60));

        byte[] otherKey = "ffffffffffffffffffffffffffffffff".getBytes(StandardCharsets.UTF_8);
        System.out.println("2) 別の鍵で検証          -> " + verify(otherKey, token, now + 60, 60));

        String[] p = token.split("\\.");
        String forged = p[0] + "." + b64(payload.replace("user-1", "admin")) + "." + p[2];
        System.out.println("3) ペイロードを書き換え  -> " + verify(key, forged, now + 60, 60));

        System.out.println("4) 期限の30秒後(許容60秒) -> " + verify(key, token, now + 930, 60));
        System.out.println("5) 期限の61秒後(許容60秒) -> " + verify(key, token, now + 961, 60));
    }
}
java -Dstdout.encoding=UTF-8 JwtHs256.java
token  = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXItMSIsImF1ZCI6InRhc2stYXBpIiwic2NvcGUiOiJ0YXNrOnJlYWQgdGFzazp3cml0ZSIsImlhdCI6MTc5MTMzMTIwMCwiZXhwIjoxNzkxMzMyMTAwfQ.0kxo-oGDJKEfXb3QOkvwhdZituUxBbRunIc54eH0wXc
長さ    = 259 文字
1) 正しい鍵・期限内      -> OK: {"iss":"https://issuer.example.com","sub":"user-1","aud":"task-api","scope":"task:read task:write","iat":1791331200,"exp":1791332100}
2) 別の鍵で検証          -> NG: 署名が一致しません
3) ペイロードを書き換え  -> NG: 署名が一致しません
4) 期限の30秒後(許容60秒) -> OK: {"iss":"https://issuer.example.com","sub":"user-1","aud":"task-api","scope":"task:read task:write","iat":1791331200,"exp":1791332100}
5) 期限の61秒後(許容60秒) -> NG: 有効期限切れ (exp=1791332100, now=1791332161)
確かめたこと 結果 わかること
正しい鍵・期限内 OK 署名と期限の両方を通る
別の鍵で検証 NG 鍵が違うと署名が一致しない
ペイロードを書き換え NG 中身を1か所変えるだけで署名が合わなくなる
期限の30秒後(許容60秒) OK 時刻のずれの許容範囲内は通す
期限の61秒後(許容60秒) NG 許容範囲を超えると期限切れ

ペイロードはBase64URLで符号化されているだけで、誰でも読めます。 守られているのは「書き換えられていないこと」です。パスワードなど読まれて困る値はペイロードに入れない、というJWTの基本がここから分かります。

自作フィルタ方式とoauth2ResourceServer方式の比較

SpringでJWT認証を組む方法は大きく2つあります。どちらでも実現できますが、新しく作るなら、Spring Securityが用意している oauth2ResourceServer 方式から始めると書く量が少なく済みます。

比べる点 自作フィルタ方式 oauth2ResourceServer方式
JWTを読む場所 自分で書いたフィルタ Spring Securityのフィルタ
署名・期限の検証 自分でライブラリを呼ぶ JwtDecoder が担当
401の応答 自分で書く WWW-Authenticate ヘッダーつきで自動
権限への変換 自分で書く JwtAuthenticationConverter
テスト 自分でトークンを作る jwt() で差し込める
合う場面 独自形式のトークンを扱う 標準的なJWTを扱う
こんなときは 選び方の目安
標準的なJWT(署名つき、exp あり)を検証したい oauth2ResourceServer方式
認可サーバー(IdP)が発行したJWTを受け取る oauth2ResourceServer方式(issuer-uri)
JWTではない独自のトークンやヘッダーを扱う 自作フィルタ方式
既存の自作フィルタが安定して動いている そのまま使い、版を上げる時に移行を検討

最小構成の全文(共通鍵HS256)

1つのアプリが「JWTを発行する」と「JWTを検証する」の両方を受け持つ構成です。ここからのコードは Spring Boot 4.1.1・Spring Security 7.1.1 の公式ドキュメントに準拠しています。

依存関係(pom.xml)

Spring Boot 4.0 でスターターの名前が変わりました。spring-boot-starter-oauth2-resource-server は spring-boot-starter-security-oauth2-resource-server に、spring-boot-starter-web は spring-boot-starter-webmvc になっています。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Spring Securityのリファレンスによると、JWTのBearerトークンに対応するリソースサーバーには spring-security-oauth2-resource-server と spring-security-oauth2-jose の両方が必要です。

application.yml

共通鍵を使う場合、鍵そのものを渡すBootの設定項目は無いので、アプリ独自のキー(ここでは app.jwt.*)で持ちます。鍵の値は設定ファイルに直接書かず、環境変数から渡します。

app:
  jwt:
    # Base64で符号化した32バイト以上の鍵。環境変数 APP_JWT_SECRET で渡す
    secret: ${APP_JWT_SECRET}
    issuer: "https://issuer.example.com"
    audience: "task-api"
    # 有効期限(ISO 8601形式。PT15M は15分)
    expires-in: PT15M

鍵は次のように作れます(32バイトの乱数をBase64にしたものです)。

openssl rand -base64 32

SecurityFilterChainとJwtDecoder(設定クラス全文)

package com.example.taskapi.config;

import java.time.Duration;
import java.util.Base64;
import java.util.List;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // Bearerトークンで守るAPIなので、セッションを使わない
            .sessionManagement((session) -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .csrf((csrf) -> csrf.disable())
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/auth/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer((oauth2) -> oauth2
                .jwt((jwt) -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );
        return http.build();
    }

    /** 設定から共通鍵を作る(HS256は32バイト以上) */
    @Bean
    public SecretKey jwtSecretKey(@Value("${app.jwt.secret}") String secret) {
        byte[] bytes = Base64.getDecoder().decode(secret);
        return new SecretKeySpec(bytes, "HmacSHA256");
    }

    /** 検証側:署名 → 期限(ずれ60秒まで許容)→ 発行者 → aud の順に確かめる */
    @Bean
    public JwtDecoder jwtDecoder(SecretKey jwtSecretKey,
            @Value("${app.jwt.issuer}") String issuer,
            @Value("${app.jwt.audience}") String audience) {
        NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(jwtSecretKey)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();

        OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
                new JwtTimestampValidator(Duration.ofSeconds(60)),
                new JwtIssuerValidator(issuer));
        OAuth2TokenValidator<Jwt> audienceValidator =
                new JwtClaimValidator<List<String>>("aud", (aud) -> aud != null && aud.contains(audience));
        OAuth2TokenValidator<Jwt> validator =
                new DelegatingOAuth2TokenValidator<>(withClockSkew, audienceValidator);

        jwtDecoder.setJwtValidator(validator);
        return jwtDecoder;
    }

    /** 発行側:同じ共通鍵で署名する */
    @Bean
    public JwtEncoder jwtEncoder(SecretKey jwtSecretKey) {
        return NimbusJwtEncoder.withSecretKey(jwtSecretKey).build();
    }

    /** roles クレーム(空白区切り)を ROLE_ 付きの権限にする */
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
        grantedAuthoritiesConverter.setAuthoritiesClaimDelimiter(" ");
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }
}

Spring Security 7 では、and() でつなぐ書き方と authorizeRequests が無くなりました。上のように、設定はすべてラムダ((x) -> x.…)で書き、認可は authorizeHttpRequests を使います。

JWTを発行するサービス(全文)

NimbusJwtEncoder のビルダーは Spring Security 7.0 で追加されました。共通鍵・RSA鍵ペア・EC鍵ペアのどれかを渡して作れます。

package com.example.taskapi.auth;

import java.time.Duration;
import java.time.Instant;
import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;

@Service
public class TokenService {

    private final JwtEncoder jwtEncoder;
    private final String issuer;
    private final String audience;
    private final Duration expiresIn;

    public TokenService(JwtEncoder jwtEncoder,
            @Value("${app.jwt.issuer}") String issuer,
            @Value("${app.jwt.audience}") String audience,
            @Value("${app.jwt.expires-in}") Duration expiresIn) {
        this.jwtEncoder = jwtEncoder;
        this.issuer = issuer;
        this.audience = audience;
        this.expiresIn = expiresIn;
    }

    /**
     * 利用者の本人確認が済んだあとに呼ぶ。
     * roles は "USER" や "USER ADMIN" のように空白区切りで渡す。
     */
    public String issue(String username, String roles) {
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer(issuer)
                .subject(username)
                .audience(List.of(audience))
                .issuedAt(now)
                .expiresAt(now.plus(expiresIn))
                .claim("roles", roles)
                .build();
        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }
}

認証された利用者を受け取るコントローラ(全文)

認証に成功すると、Authentication は JwtAuthenticationToken 型になり、その中にJWT(Jwt)が入っています。

package com.example.taskapi.web;

import java.util.List;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {

    @GetMapping("/tasks")
    public List<String> tasks() {
        return List.of("資料を作る", "レビューを依頼する");
    }

    @GetMapping("/me")
    public Map<String, Object> me(JwtAuthenticationToken authentication) {
        return Map.of(
                "name", authentication.getName(),
                "authorities", authentication.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList(),
                "expiresAt", String.valueOf(authentication.getToken().getExpiresAt()));
    }

    @GetMapping("/admin/reports")
    public List<String> reports() {
        return List.of("月次レポート");
    }
}

公開鍵(RS256)で検証する2系統目

発行する側と検証する側が別のアプリのときは、発行側だけが秘密鍵を持ち、検証側は公開鍵だけを持つ形にすると、鍵を配る範囲を小さくできます。

設定だけで済ませる(public-key-location)

公開鍵のファイル(PEM形式のX.509)を置き、場所を設定に書きます。この場合、JwtDecoder のBeanを自分で書かなくてもSpring Bootが用意します。

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:my-key.pub

JwtDecoderを自分で組み立てる(withPublicKey)

検証器を足したいときは、Beanを自分で定義します。@Value に RSAPublicKey 型を指定すると、鍵ファイルの場所から鍵に変換されます。

key:
  location: classpath:my-key.pub
app:
  jwt:
    issuer: "https://issuer.example.com"
package com.example.taskapi.config;

import java.security.interfaces.RSAPublicKey;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

@Configuration
public class PublicKeyJwtConfig {

    @Value("${key.location}")
    RSAPublicKey key;

    @Value("${app.jwt.issuer}")
    String issuer;

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(this.key).build();
        jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(this.issuer));
        return jwtDecoder;
    }
}

(共通鍵版の SecurityConfig と同時には使いません。JwtDecoder のBeanはどちらか一方にします。)

認可サーバーが発行したJWTを受け取る(issuer-uri)

認可サーバー(IdP)を使う構成では、設定は1項目で済みます。

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/issuer

リファレンスによると、この設定でリソースサーバーは起動時に認可サーバーのメタデータから jwks_url を調べ、公開鍵で署名を検証し、iss を issuer-uri の値と照合するように構成されます。実行時には、署名、exp と nbf、iss を検証し、スコープを SCOPE_ 付きの権限に対応付けます。

鍵の種類 作り方 合う場面
共通鍵(HS256) NimbusJwtDecoder.withSecretKey(key) 発行と検証が同じアプリ
公開鍵(RS256) NimbusJwtDecoder.withPublicKey(key) 発行と検証が別のアプリ
JWK Set issuer-uri/jwk-set-uri 認可サーバーを使う、鍵を入れ替える

spring.security.oauth2.resourceserver.jwtの設定項目一覧

設定項目 内容
issuer-uri 発行者のURI。メタデータから鍵の場所を調べ、iss も照合する
jwk-set-uri 公開鍵の一覧(JWK Set)を取得するURI
public-key-location 公開鍵ファイル(PEM形式のX.509)の場所
jws-algorithms 信頼する署名アルゴリズム(例:RS512)
audiences 受け付ける aud の値

全部を使った例です。issuer-uri と jwk-set-uri を両方書くと、鍵の取得先を明示しつつ iss の照合も行えます。

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com
          jwk-set-uri: https://idp.example.com/.well-known/jwks.json
          jws-algorithms: RS512
          audiences: https://my-resource-server.example.com

リファレンスには接続まわりの既定値も書かれています。認可サーバーとの通信の接続・読み取りタイムアウトはそれぞれ30秒、JWK Setのメモリ上のキャッシュは5分です。

権限の対応付け(JwtAuthenticationConverter)

何も設定しない場合、scope(または scp)クレームの値が、SCOPE_ を前に付けた権限になります。 たとえば "scope": "messages contacts" は SCOPE_messages と SCOPE_contacts になります。

やりたいこと 設定
別のクレームから権限を作る setAuthoritiesClaimName("roles")
前に付ける文字を変える setAuthorityPrefix("ROLE_")
前に付ける文字をなくす setAuthorityPrefix("")
区切り文字を変える setAuthoritiesClaimDelimiter(" ")
利用者名に使うクレームを変える JwtAuthenticationConverter#setPrincipalClaimName
クレームの例 設定 できる権限 認可の書き方
"scope": "task:read" 既定 SCOPE_task:read access(hasScope("task:read"))
"roles": "ADMIN" クレーム名 roles、接頭辞 ROLE_ ROLE_ADMIN hasRole("ADMIN")
"authorities": "task.edit" クレーム名 authorities、接頭辞なし task.edit hasAuthority("task.edit")

スコープで認可する場合の書き方です。hasScope は OAuth2AuthorizationManagers から静的インポートします。

package com.example.taskapi.config;

import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class ScopeSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/tasks/**").access(hasScope("task:read"))
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

検証器の組み立て方(時刻のずれ・発行者・audience)

JwtDecoder は署名を確かめたあと、検証器(OAuth2TokenValidator)にクレームの確認を任せます。 検証器は部品を組み合わせて作ります。

部品 確かめる内容
JwtTimestampValidator exp と nbf。時刻のずれの許容幅を引数で指定できる
JwtIssuerValidator iss が期待した発行者か
JwtClaimValidator 任意のクレームを条件式で確かめる
DelegatingOAuth2TokenValidator 複数の検証器をまとめて1つにする
JwtValidators.createDefault() 標準の検証器一式(時刻・X.509サムプリント・type)
JwtValidators.createDefaultWithIssuer(issuer) 標準一式に発行者の確認を足したもの
JwtValidators.createDefaultWithValidators(...) 標準一式に独自の検証器を足したもの
JwtValidators.createAtJwtValidator() RFC 9068 に沿った検証器を組み立てるビルダー

サーバー同士の時計は少しずれることがあるため、期限の確認には許容幅を持たせます。最初のJavaのコードで「期限の30秒後はOK、61秒後はNG」となったのと同じ考え方で、Spring Securityでは次のように書きます。

OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
        new JwtTimestampValidator(Duration.ofSeconds(60)),
        new JwtIssuerValidator(issuerUri));
jwtDecoder.setJwtValidator(withClockSkew);

audience(aud)は「このトークンはどのAPI向けか」を表します。自分のAPI向けのトークンだけを受け付けるようにすると、別のAPI向けに発行されたトークンを持ち込まれても通しません。 設定で済ませるなら audiences、コードで書くなら次の形です。

OAuth2TokenValidator<Jwt> audienceValidator =
        new JwtClaimValidator<List<String>>("aud", (aud) -> aud != null && aud.contains("task-api"));
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
jwtDecoder.setJwtValidator(withAudience);

(上の2つは、先ほどの SecurityConfig の jwtDecoder メソッドの中に入る部分です。動く形の全文は SecurityConfig を見てください。)

401と403がどこで決まるかの判断表

401は「誰なのか確認できなかった」、403は「誰かは分かったが権限が足りない」 です。Spring Securityでは ExceptionTranslationFilter が、認証の例外なら AuthenticationEntryPoint を、認可の例外なら AccessDeniedHandler を呼びます。

flowchart TD
  A[リクエスト] --> B{Authorizationヘッダーに<br>Bearerトークンがあるか}
  B -- ない --> C{そのパスは<br>permitAllか}
  C -- はい --> OK[処理に進む]
  C -- いいえ --> E401[401]
  B -- ある --> D{JwtDecoderの検証を<br>通るか}
  D -- 通らない --> E401
  D -- 通る --> F{必要な権限を<br>持っているか}
  F -- 持っている --> OK
  F -- 持っていない --> E403[403]
状況 応答 決まる場所
トークンなしで保護されたパスにアクセス 401 AuthenticationEntryPoint
署名が一致しない 401 JwtDecoder(署名の検証)
有効期限が切れている 401 JwtTimestampValidator
発行者(iss)が違う 401 JwtIssuerValidator
aud が違う 401 audienceの検証器
トークンは有効だが権限が足りない 403 AccessDeniedHandler
permitAll のパス 処理に進む authorizeHttpRequests

トークンが無効なとき、リソースサーバーは InvalidBearerTokenException を投げ、応答は次のような形になります(リファレンスに載っている例です)。

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error_code="invalid_token", error_description="Unsupported algorithm of none", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
困りごと 見るところ
正しいはずのトークンで401になる 鍵、iss、aud、サーバーの時刻。WWW-Authenticate の error_description
403になる 権限の名前(SCOPE_ や ROLE_ の接頭辞)と、hasRole/hasScope の指定
hasRole("ADMIN") が通らない 権限が ROLE_ADMIN になっているか(接頭辞の設定)
トークンを別のヘッダーで受け取りたい DefaultBearerTokenResolver#setBearerTokenHeaderName

MockMvcでテストする

テストでは本物のトークンを作らなくても、jwt() で認証済みの状態を差し込めます。 Spring Boot 4 では、@SpringBootTest だけではMockMvcが用意されなくなったので、@AutoConfigureMockMvc を付けます。

package com.example.taskapi;

import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest(properties = {
        "app.jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
        "app.jwt.issuer=https://issuer.example.com",
        "app.jwt.audience=task-api",
        "app.jwt.expires-in=PT15M" })
@AutoConfigureMockMvc
class TaskApiSecurityTests {

    @Autowired
    private MockMvc mvc;

    @Test
    void トークンなしは401() throws Exception {
        mvc.perform(get("/tasks"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    void 有効なJWTがあれば200() throws Exception {
        mvc.perform(get("/tasks").with(jwt()))
                .andExpect(status().isOk());
    }

    @Test
    void ADMIN権限がなければ403() throws Exception {
        mvc.perform(get("/admin/reports").with(jwt()))
                .andExpect(status().isForbidden());
    }

    @Test
    void ADMIN権限があれば200() throws Exception {
        mvc.perform(get("/admin/reports")
                        .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
                .andExpect(status().isOk());
    }
}

テスト用の app.jwt.secret は、説明用の32バイトの文字列をBase64にした値です(printf '%s' 0123456789abcdef0123456789abcdef | base64 で作れます)。

jwt() の使い方 意味
jwt() 既定のJWT(sub は user、scope は read)
jwt().jwt((jwt) -> jwt.claim("iss", "...")) クレームやヘッダーを指定する
jwt().authorities(new SimpleGrantedAuthority("SCOPE_messages")) 権限を直接指定する

Boot 3から4で変わる点の対応表

Spring Boot 3(Spring Security 6)向けに書かれたコードを Boot 4(Spring Security 7)に上げるときの読み替えです。

項目 Boot 3/Security 6 Boot 4/Security 7
リソースサーバーのスターター spring-boot-starter-oauth2-resource-server spring-boot-starter-security-oauth2-resource-server
Webのスターター spring-boot-starter-web spring-boot-starter-webmvc
認可サーバーのスターター spring-boot-starter-oauth2-authorization-server spring-boot-starter-security-oauth2-authorization-server
認可サーバーの位置づけ 別プロジェクト Spring Securityの一部
DSLのつなぎ方 and() も使えた ラムダのみ(and() は削除)
認可の設定 authorizeRequests も使えた authorizeHttpRequests(authorizeRequests は削除)
パスのマッチャー AntPathRequestMatcher・MvcRequestMatcher PathPatternRequestMatcher
JWTの発行 JWKSource を渡して作る NimbusJwtEncoder.withSecretKey などのビルダーが追加
JSONライブラリ Jackson 2 Jackson 3(tools.jackson)が既定
テストのMockMvc @SpringBootTest で用意された @AutoConfigureMockMvc を付ける
テストのモック @MockBean・@SpyBean @MockitoBean・@MockitoSpyBean
@WithMockUser など spring-security-test spring-boot-starter-security-test が必要
OAuth 2.0のパスワードグラント 利用できた サポート終了

移行の順番として、Spring Securityのリファレンスは「まず Spring Security 6.5 に上げて準備の手順を済ませ、そのあと 7.0 に上げる」流れを案内しています。

Spring Bootの世代ごとのサポート期間

版を選ぶときの参考に、Springの公式API(api.spring.io、確認日 2026年10月7日)が返すサポート期間を載せます。

世代 初回リリース OSSサポート終了 商用サポート終了
3.4.x 2024-11-30 2025-12-31 2026-12-31
3.5.x 2025-05-31 2026-06-30 2032-06-30
4.0.x 2025-11-30 2026-12-31 2027-12-31
4.1.x 2026-06-30 2027-07-31 2028-07-31

運用で押さえる点のチェックリスト

  • [ ] 共通鍵は32バイト以上の乱数にして、環境変数など設定ファイルの外から渡している
  • [ ] アクセストークンの有効期限を短め(例:15分)にしている
  • [ ] iss と aud を検証器で確かめている
  • [ ] 時刻のずれの許容幅を決めている(例:60秒)
  • [ ] ペイロードに読まれて困る値を入れていない
  • [ ] 権限の接頭辞(SCOPE_/ROLE_)と、hasScope/hasRole の指定がそろっている
  • [ ] 401と403のテストを書いている
  • [ ] 通信はHTTPSにしている

よくある質問

Q. JWT認証のために自分でフィルタを書く必要はありますか? A. 標準的なJWTであれば、oauth2ResourceServer の設定と JwtDecoder のBeanで足ります。Bearerトークンの取り出し、検証、401の応答はSpring Securityが受け持ちます。

Q. 共通鍵(HS256)と公開鍵(RS256)はどちらを選べばよいですか? A. 発行と検証を同じアプリで行うなら共通鍵が手軽です。発行する側と検証する側が分かれるなら、検証側に公開鍵だけを置ける公開鍵方式が向いています。

Q. 権限が SCOPE_ で始まるのはなぜですか? A. 既定では scope(または scp)クレームの値に SCOPE_ を付けて権限にするためです。JwtGrantedAuthoritiesConverter でクレーム名と接頭辞を変えられます。

Q. Boot 3向けの記事のコードはBoot 4でも動きますか? A. 考え方はそのまま使えます。スターターの名前、and() と authorizeRequests の削除、テストの @AutoConfigureMockMvc などを、上の対応表に沿って読み替えてください。

まとめ

  • JWT認証は oauth2ResourceServer と JwtDecoder のBeanで組める。検証と401の応答はSpring Securityが受け持つ
  • 鍵は2系統:同じアプリで発行と検証をするなら withSecretKey、分かれるなら withPublicKey や issuer-uri
  • 既定の権限は SCOPE_ 付き。JwtAuthenticationConverter でクレーム名と接頭辞を変える
  • 検証器で期限(ずれの許容つき)・発行者・audを確かめる
  • 401は認証、403は認可。どの部品で決まるかを判断表で切り分ける
  • Boot 4ではスターター名が変わり、and() と authorizeRequests が無くなった
  • JWTのペイロードは誰でも読める。守られているのは書き換えられていないこと

参考・出典

確認日はすべて 2026年10月7日 です。

  • Spring Security リファレンス OAuth 2.0 Resource Server JWT(7.1.1):https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
  • Spring Security リファレンス OAuth 2.0 Bearer Tokens:https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/bearer-tokens.html
  • Spring Security リファレンス Architecture(Handling Security Exceptions):https://docs.spring.io/spring-security/reference/servlet/architecture.html
  • Spring Security リファレンス Testing OAuth 2.0:https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/oauth2.html
  • Spring Security リファレンス CSRF:https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html
  • Spring Security リファレンス Session Management:https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
  • Spring Security 7.0 What's New:https://docs.spring.io/spring-security/reference/7.0/whats-new.html
  • Spring Security リファレンス Migration:https://docs.spring.io/spring-security/reference/migration/index.html
  • Spring Security API NimbusJwtEncoder:https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.html
  • Spring Security API JwtValidators:https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/oauth2/jwt/JwtValidators.html
  • Spring Boot リファレンス OAuth2(4.1.1):https://docs.spring.io/spring-boot/reference/security/oauth2.html
  • Spring Boot リファレンス Testing Spring Boot Applications(4.1.1):https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html
  • Spring Boot 4.0 Migration Guide:https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide
  • Spring Boot 世代別サポート期間:https://api.spring.io/projects/spring-boot/generations

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Security JWT認証の実装|Boot 4対応の設定コード全文」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?