- Spring
Spring Security JWT認証の実装|Boot 4対応の設定コード全文
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. |
spring- |
| JWT認証を有効にする | Security |
oauth2 |
| 共通鍵(HS256)で検証 | Jwt のBean |
Nimbus |
| 公開鍵(RS256)で検証 | Jwt のBeanか設定 |
Nimbus/public- |
| 認可サーバーの鍵で検証 | application. |
issuer-/jwk- |
| クレームを権限にする | Jwt |
set・set |
| 期限・発行者・audの検証 | set |
Jwt など |
| パスごとの認可 | authorize |
has・has・authenticated |
| JWTを発行する | Jwt のBean |
Nimbus |
| テスト | 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のフィルタ |
| 署名・期限の検証 | 自分でライブラリを呼ぶ | Jwt が担当 |
| 401の応答 | 自分で書く | WWW- ヘッダーつきで自動 |
| 権限への変換 | 自分で書く | Jwt |
| テスト | 自分でトークンを作る | jwt( で差し込める |
| 合う場面 | 独自形式のトークンを扱う | 標準的なJWTを扱う |
| こんなときは | 選び方の目安 |
|---|---|
標準的なJWT(署名つき、exp あり)を検証したい |
oauth2ResourceServer方式 |
| 認可サーバー(IdP)が発行したJWTを受け取る | oauth2ResourceServer方式(issuer-) |
| 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) | Nimbus |
発行と検証が同じアプリ |
| 公開鍵(RS256) | Nimbus |
発行と検証が別のアプリ |
| JWK Set | issuer-/jwk- |
認可サーバーを使う、鍵を入れ替える |
spring.security.oauth2.resourceserver.jwtの設定項目一覧
| 設定項目 | 内容 |
|---|---|
issuer- |
発行者のURI。メタデータから鍵の場所を調べ、iss も照合する |
jwk- |
公開鍵の一覧(JWK Set)を取得するURI |
public- |
公開鍵ファイル(PEM形式のX.509)の場所 |
jws- |
信頼する署名アルゴリズム(例: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 になります。
| やりたいこと | 設定 |
|---|---|
| 別のクレームから権限を作る | set |
| 前に付ける文字を変える | set |
| 前に付ける文字をなくす | set |
| 区切り文字を変える | set |
| 利用者名に使うクレームを変える | Jwt |
| クレームの例 | 設定 | できる権限 | 認可の書き方 |
|---|---|---|---|
"scope": "task: |
既定 | SCOPE_ |
access( |
"roles": "ADMIN" |
クレーム名 roles、接頭辞 ROLE_ |
ROLE_ |
has |
"authorities": "task. |
クレーム名 authorities、接頭辞なし |
task. |
has |
スコープで認可する場合の書き方です。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)にクレームの確認を任せます。 検証器は部品を組み合わせて作ります。
| 部品 | 確かめる内容 |
|---|---|
Jwt |
exp と nbf。時刻のずれの許容幅を引数で指定できる |
Jwt |
iss が期待した発行者か |
Jwt |
任意のクレームを条件式で確かめる |
Delegating |
複数の検証器をまとめて1つにする |
Jwt |
標準の検証器一式(時刻・X.509サムプリント・type) |
Jwt |
標準一式に発行者の確認を足したもの |
Jwt |
標準一式に独自の検証器を足したもの |
Jwt |
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 | Authentication |
| 署名が一致しない | 401 | Jwt(署名の検証) |
| 有効期限が切れている | 401 | Jwt |
発行者(iss)が違う |
401 | Jwt |
aud が違う |
401 | audienceの検証器 |
| トークンは有効だが権限が足りない | 403 | Access |
permit のパス |
処理に進む | authorize |
トークンが無効なとき、リソースサーバーは 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- の error_ |
| 403になる | 権限の名前(SCOPE_ や ROLE_ の接頭辞)と、has/has の指定 |
has が通らない |
権限が ROLE_ になっているか(接頭辞の設定) |
| トークンを別のヘッダーで受け取りたい | Default |
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( |
権限を直接指定する |
Boot 3から4で変わる点の対応表
Spring Boot 3(Spring Security 6)向けに書かれたコードを Boot 4(Spring Security 7)に上げるときの読み替えです。
| 項目 | Boot 3/Security 6 | Boot 4/Security 7 |
|---|---|---|
| リソースサーバーのスターター | spring- |
spring- |
| Webのスターター | spring- |
spring- |
| 認可サーバーのスターター | spring- |
spring- |
| 認可サーバーの位置づけ | 別プロジェクト | Spring Securityの一部 |
| DSLのつなぎ方 | and( も使えた |
ラムダのみ(and( は削除) |
| 認可の設定 | authorize も使えた |
authorize(authorize は削除) |
| パスのマッチャー | Ant・Mvc |
Path |
| JWTの発行 | JWKSource を渡して作る |
Nimbus などのビルダーが追加 |
| JSONライブラリ | Jackson 2 | Jackson 3(tools.)が既定 |
| テストのMockMvc | @Spring で用意された |
@Auto を付ける |
| テストのモック | @Mock・@Spy |
@Mockito・@Mockito |
@With など |
spring- |
spring- が必要 |
| 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期連続の黒字決算。