13 人が閲覧(直近 30 日) Spring

Spring Bootの認証実装|DB認証・ログインAPI・remember-me・JWTの書き方【Spring Security 7.1】

Spring Bootの認証実装|DB認証・ログインAPI・remember-me・JWTの書き方【Spring Security 7.1】

Spring Bootで認証を実装しようとして、参考にしたコードがコンパイルできない場合は、まず WebSecurityConfigurerAdapter や authorizeRequests() といった削除済みのAPIを使っていないかを確認してください。この記事では、2026年10月22日に公開された Spring Boot 4.1.2(Spring Security 7.1.2)で検証アプリを起動し、curl と MockMvc で確かめた挙動をもとに、データベース認証・独自ログインAPI・remember-me・カスタム AuthenticationProvider・JWT認証の書き方を順に解説します。検証環境は Spring Boot 4.1.2・Spring Security 7.1.2・Java 25(Temurin)・H2 2.4.240、検証日は2026年10月26日です。

まとめ:Spring Bootで認証を実装するときの要点

  • 設定は SecurityFilterChain を返すBeanとラムダDSLで書きます。WebSecurityConfigurerAdapter は Spring Security 6.0、authorizeRequests() は 7.0 で削除されています。
  • DBのユーザーで認証するなら、UserDetailsService の実装と PasswordEncoderFactories.createDelegatingPasswordEncoder() をBeanにします。保存されるハッシュは {bcrypt} で始まります。
  • JSONを受け取る独自のログインAPIでは、SecurityContextRepository.saveContext() を呼ばないと、ログインできても次のリクエストで401になります。セッションIDの振り直しも自分で呼び、CSRF保護は csrf.spa() で残します。
  • AuthenticationProvider をBeanで自動登録させる場合、Beanが1つなら UserDetailsService による自動構成が使われなくなり、2つ以上ならどちらのプロバイダも自動登録されません。
  • JWTは自作フィルタより、OAuth2 Resource Server と NimbusJwtEncoder/NimbusJwtDecoder で組むほうがコードが短く、改ざんや alg:none の拒否も任せられます。

Spring Boot 4.1で認証を組む前提とバージョン対応

Spring BootとSpring Securityのバージョンとサポート期限

認証と認可の違いや Spring Security の全体像はSpring Securityとは?認証・認可の仕組みと基本設定を7.1で解説にまとめているので、この記事では実装に絞ります。Spring Boot の各版は、依存関係管理によって既定の Spring Security の版を指定しています。明示的に上書きしている場合は、実際に解決された依存バージョンを確認してください。Spring Bootのサポート表(spring.io の generations API)の値は次のとおりです。3.5系の無償サポートは2026年6月30日に終わっているため、これから書くコードは4.1系を前提にします。

Spring Boot Spring Security 初回リリース OSSサポート終了
3.5.x 6.5.x 2025年5月 2026-06-30(終了)
4.0.x 7.0.x 2025年11月 2026-12-31
4.1.x(最新4.1.2) 7.1.x(最新7.1.2) 2026年6月 2027-07-31
4.2.x(RC1公開済み) 7.2.x 2026年11月予定 2027-12-31

各版の変更点と期限の一覧はSpring Bootバージョン一覧とサポート期限(EOL)【2026年10月】最新版は4.1、4.0の無償サポートは12月末終了にまとめています。

古い認証コードが動かない理由と置き換え先

検索で見つかる認証のサンプルには Spring Security 5 系の書き方が多く残っています。Spring Security 7.1.2 のソースを確認すると、HttpSecurity には authorizeHttpRequests しかなく、formLogin などの設定メソッドも Customizer を受け取る形だけになっています。

旧コード 現行の書き方 削除された版
WebSecurityConfigurerAdapter を継承 SecurityFilterChain をBeanで返す Spring Security 6.0
antMatchers/mvcMatchers requestMatchers Spring Security 6.0
authorizeRequests authorizeHttpRequests Spring Security 7.0
.and() で設定をつなぐ ラムダ(Customizer)で書く Spring Security 7.0
javax.servlet/javax.persistence jakarta.servlet/jakarta.persistence Spring Boot 3.0

Spring Boot 4 系のそのほかの変更点(starter名の変更など)はSpring Boot 4とは?最新バージョン4.1の変更点・新機能とSpring Boot 3との違い【2026年10月】で扱っています。

SecurityFilterChainによるログイン画面とアクセス制御の設定

starterの追加と依存だけのときの既定動作

認証の土台は spring-boot-starter-security です。Spring Boot 4 では Web MVC の starter 名が spring-boot-starter-webmvc に変わっています。

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

依存を足しただけで起動すると、全URLが認証必須になります。ブラウザ(Accept: text/html)からのアクセスは /login へ302で転送され、curl の既定(Accept: */*)でアクセスすると401と WWW-Authenticate: Basic が返ります。ユーザー名は user で、パスワードは起動ログの Using generated security password: の行に出ます。この生成パスワードは UserDetailsService のBeanを定義した時点で出なくなります。

フォームログイン・Basic認証・CSRF対策の最小構成

次の設定は、/public/** とログインAPIだけを公開し、残りを認証必須にします。フォームログインとBasic認証を両方有効にし、@EnableMethodSecurity でメソッド単位の認可も使えるようにしています。フィルタが実行される順序の詳細はSecurityFilterChainとは?Spring Securityのフィルタ実行順序と設定例【Spring Security 7.1対応】を参照してください。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/api/login").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form.permitAll())
            .httpBasic(basic -> {})
            .csrf(csrf -> csrf.spa())
            .exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
                new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                PathPatternRequestMatcher.withDefaults().matcher("/api/**")));
        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

独自のログイン画面にする場合は form.loginPage("/login").permitAll() とし、username・password・_csrf をPOSTするテンプレートを用意します。csrf.spa()(Spring Security 7.0 で追加)は、CSRFトークンを XSRF-TOKEN Cookie で渡し、X-XSRF-TOKEN ヘッダーで受け取る設定です。検証では最初のGETで XSRF-TOKEN Cookie が発行され、ヘッダーを付けないログインAPIへのPOSTは拒否、付けると200でした。セッションCookieで認証する以上、/api/** でもCSRF保護は外さないでください。CSRFトークンのエラーが出たときの切り分けはInvalid CSRF tokenの意味と直し方|Laravel 419・Django・Spring・Expressのエラー別に切り分けで解説しています。

API向けの401応答とメソッド単位の認可

defaultAuthenticationEntryPointFor を指定すると、未認証で /api/me を呼んだときの応答は、ログイン画面への302ではなく401になります。検証では WWW-Authenticate ヘッダーも付きませんでした。一方で、誤ったパスワードをBasic認証で送ると、Basic認証のフィルタが自身のエントリーポイントを使うため、WWW-Authenticate: Basic realm="Realm", charset="UTF-8" が付いた401が返ります。APIの401の形をそろえたいときは、httpBasic(basic -> basic.authenticationEntryPoint(...)) にも同じエントリーポイントを渡します。

メソッド単位の認可は @PreAuthorize("hasRole('ADMIN')") で書けます。USERロールの alice が呼ぶと403、ADMINロールのユーザーが呼ぶと200でした。@Secured は @EnableMethodSecurity(securedEnabled = true) にしないと評価されない点に注意してください。

UserDetailsServiceとPasswordEncoderによるDBユーザー認証

ユーザーエンティティとUserDetailsServiceの実装

DB認証には spring-boot-starter-data-jpa とJDBCドライバー(検証ではH2)も追加します。Spring Data JPA でユーザーテーブルを読み、UserDetails に詰め替えて返します。@Service を付けた UserDetailsService が1つだけあれば、起動時に Global AuthenticationManager configured with UserDetailsService bean with name dbUserDetailsService とログに出て、フォームログインとBasic認証の両方で使われます。

import jakarta.persistence.*;

@Entity
@Table(name = "app_user")
public class AppUser {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false, unique = true)
    private String username;
    @Column(nullable = false)
    private String password;   // {bcrypt}$2a$10$... の形で保存する
    @Column(nullable = false)
    private String role;       // USER や ADMIN(ROLE_ は付けない)
    private boolean enabled = true;
    // コンストラクタとgetterは省略
}

public interface AppUserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByUsername(String username);
}

@Service
public class DbUserDetailsService implements UserDetailsService {
    private final AppUserRepository repository;

    public DbUserDetailsService(AppUserRepository repository) {
        this.repository = repository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        AppUser u = repository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(username));
        return User.withUsername(u.getUsername())
            .password(u.getPassword())
            .roles(u.getRole())
            .disabled(!u.isEnabled())
            .build();
    }
}

存在しないユーザー名でログインしても、応答は誤ったパスワードと同じ BadCredentialsException になりました。UsernameNotFoundException をそのまま返すと、ユーザー名が登録済みかどうかを外部から確かめられてしまうため、Spring Security が標準で隠しています。ログインAPIのエラーメッセージも「ユーザー名またはパスワードが違います」に統一してください。

PasswordEncoderの選び方(BCrypt・Argon2)

createDelegatingPasswordEncoder() は新しいパスワードを BCrypt(強度10)でハッシュ化し、{bcrypt}$2a$10$... のように先頭へアルゴリズムIDを付けます。照合時はこのIDを見てエンコーダーを切り替えるため、あとから既定のアルゴリズムを変えても、既存ユーザーのハッシュはそのまま照合できます。new BCryptPasswordEncoder() を直接Beanにすると、このID付きの移行が使えません。

Argon2を使う場合、Spring Security の Argon2PasswordEncoder は公式のPassword Storageの説明にあるとおり BouncyCastle(org.bouncycastle:bcprov-jdk18on)を別途必要とします。defaultsForSpringSecurity_v5_8() の既定値はメモリ16MiB・反復2回・並列度1で、OWASP Password Storage Cheat Sheetが挙げる最小構成の一例(19MiB・反復2回・並列度1)より少しメモリが小さくなります。OWASPの値に合わせるならコンストラクタで new Argon2PasswordEncoder(16, 32, 1, 19456, 2) と指定します。

OWASPはbcryptをレガシーシステム向けと位置づけ、強度10以上・パスワード72バイト上限を条件にしています。Spring Security のBCrypt実装も、72バイトを超えるパスワードをハッシュ化しようとすると IllegalArgumentException: password cannot be more than 72 bytes を投げます。BCryptを使い続けるなら、ユーザー登録の入力チェックで上限を設けてください。

JSONログインAPIで必要な認証状態の保存とセッションID変更

SPAやモバイルアプリからJSONでログインさせたいとき、コントローラーで AuthenticationManager を呼ぶ実装がよく使われます。Spring Security 6 以降はここに落とし穴があります。SecurityContextHolder に認証結果を入れただけでは、セッションに保存されません。

検証では比較のため、saveContext を呼ばずに request.getSession(true) でセッションだけを作るAPIを用意しました。ログイン自体は200を返し、JSESSIONID のCookieも発行されました。しかし、そのCookieで /api/me を呼ぶと401でした。公式のSession Managementのページにも、リクエスト間で認証状態を保持するには SecurityContextRepository へ明示的に保存する必要があると書かれています。次のように保存すると200になりました。

@Configuration
public class AuthBeans {
    @Bean
    SecurityContextRepository securityContextRepository() {
        return new HttpSessionSecurityContextRepository();
    }

    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

@RestController
public class LoginController {
    private final AuthenticationManager authenticationManager;
    private final SecurityContextRepository securityContextRepository;
    private final SessionAuthenticationStrategy sessionStrategy = new CompositeSessionAuthenticationStrategy(List.of(
        new ChangeSessionIdAuthenticationStrategy(),                                     // セッションIDを振り直す
        new CsrfAuthenticationStrategy(CookieCsrfTokenRepository.withHttpOnlyFalse()))); // CSRFトークンを作り直す

    public LoginController(AuthenticationManager am, SecurityContextRepository repo) {
        this.authenticationManager = am;
        this.securityContextRepository = repo;
    }

    record LoginRequest(String username, String password) {}

    @PostMapping("/api/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest body,
                                   HttpServletRequest request, HttpServletResponse response) {
        try {
            Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(body.username(), body.password()));
            sessionStrategy.onAuthentication(auth, request, response);   // セッションIDとCSRFトークンを更新
            SecurityContext context = SecurityContextHolder.createEmptyContext();
            context.setAuthentication(auth);
            SecurityContextHolder.setContext(context);
            securityContextRepository.saveContext(context, request, response);   // これが無いと次のリクエストで401
            return ResponseEntity.ok(Map.of("user", auth.getName()));
        } catch (AuthenticationException e) {
            return ResponseEntity.status(401).body(Map.of("error", "invalid credentials"));
        }
    }
}

HttpSessionSecurityContextRepository は、フィルタチェーンが次のリクエストで読み込むのと同じセッション属性(SPRING_SECURITY_CONTEXT)に認証結果を書き込みます。フォームログインと違い、コントローラーで認証すると、ログイン時のセッションIDの振り直し(セッション固定攻撃対策)とCSRFトークンの作り直しは自動では実行されません。コード中の CompositeSessionAuthenticationStrategy がその代わりです。検証では、ログイン前に発行されたセッションIDがログイン後に別の値へ変わり、XSRF-TOKEN Cookie はログインの応答で削除され、次のGETで新しい値が発行されました。続けて /logout へPOSTしたところ、ログイン後のCookieを送りつつヘッダーにだけログイン前のトークンを指定すると403、新しいトークンを指定すると204でした(curl の既定のAcceptヘッダーの場合。HTMLを要求するブラウザではログアウト後にリダイレクトされます)。CookieCsrfTokenRepository.withHttpOnlyFalse() は csrf.spa() が使う設定と同じCookie名・ヘッダー名です。Cookieの設定を変える場合は、フィルタと同じ CsrfTokenRepository を渡してください。

このAPIを呼ぶクライアントは、先にGETで受け取った XSRF-TOKEN Cookie の値を X-XSRF-TOKEN ヘッダーに入れてPOSTします。curl で再現すると次のとおりです。

curl -c jar http://localhost:8080/public/hello
curl -b jar -c jar -H "Content-Type: application/json" \
     -H "X-XSRF-TOKEN: (jarに保存されたXSRF-TOKENの値)" \
     -d '{"username":"alice","password":"password"}' \
     http://localhost:8080/api/login

ログイン後に返す情報や権限の一覧には、Spring Security 7 から FACTOR_PASSWORD という多要素認証用の権限が自動で加わります。権限一覧をそのまま画面に出している場合は、ROLE_ で始まるものだけに絞ってください。セッションの保存先や失効の設計はセッション管理とは?セッションIDの発行から失効までの実装と保存先選定を解説で詳しく説明しています。

remember-meのトークンのDB保存と盗用検知

ブラウザを閉じてもログイン状態を残すには remember-me を使います。トークンをDBに保存する方式(PersistentTokenRepository)なら、サーバー側でトークンを失効できます。テーブル定義は公式のRemember-Meのページに載っている4列です。

-- src/main/resources/schema.sql
create table if not exists persistent_logins (
    username  varchar(64) not null,
    series    varchar(64) primary key,
    token     varchar(64) not null,
    last_used timestamp   not null
);
@Bean
PersistentTokenRepository persistentTokenRepository(DataSource dataSource) {
    JdbcTokenRepositoryImpl tokens = new JdbcTokenRepositoryImpl();
    tokens.setDataSource(dataSource);
    return tokens;
}

// SecurityFilterChain の中で
http.rememberMe(rm -> rm
    .tokenRepository(persistentTokenRepository)
    .userDetailsService(userDetailsService));

ログインフォームで remember-me=on を送ると、remember-me Cookie(HttpOnly・Max-Age=1209600秒=14日)が発行されました。このCookieだけで /api/me を呼ぶと200になり、そのたびにトークンが新しい値へ入れ替わります。入れ替え前の古いCookieを送ると401になり、その後は新しいCookieでも401でした。同じ系列で古いトークンが提示されると盗用とみなし、そのユーザーのトークンをまとめて無効にする仕組みです。

実装で起きやすい失敗が1つあります。JdbcTokenRepositoryImpl を SecurityFilterChain のメソッド内で new し、setCreateTableOnStartup(true) を指定しても、Beanとして初期化されないためテーブルは作られません。この状態ではログイン自体は302で成功し、ログにERRORが1行出るだけで、remember-me のCookieは発行されません。上の例のようにBeanとして定義し、テーブルは schema.sql かマイグレーションで作ってください。また、remember-me で復元したログインの権限には FACTOR_PASSWORD が含まれませんでした。重要な操作で再入力を求める判定に使えます。

カスタムAuthenticationProviderによる独自照合の組み込み

AuthenticationProviderが必要な場面と不要な場面

ユーザー名とパスワードをDBのハッシュと照合するだけなら、UserDetailsService で足ります。AuthenticationProvider を自作するのは、社内の既存認証APIへ照合を委ねる、テナントコードなど3つ目の入力で照合先を変える、ワンタイムトークンのように UserDetails に収まらない資格情報を扱う、といった場面です。Servlet Authentication Architectureのとおり、ProviderManager は登録されたプロバイダを順に試し、null を返したプロバイダは「判断できない」として次へ回します。

public class LegacyApiAuthenticationProvider implements AuthenticationProvider {

    private final LegacyAuthClient client;   // 既存の社内認証APIのクライアント

    public LegacyApiAuthenticationProvider(LegacyAuthClient client) {
        this.client = client;
    }

    @Override
    public Authentication authenticate(Authentication authentication) {
        String name = authentication.getName();
        if (!name.startsWith("legacy-")) {
            return null;   // 担当外なので次のプロバイダへ
        }
        String password = String.valueOf(authentication.getCredentials());
        if (!client.verify(name, password)) {
            throw new BadCredentialsException("invalid credentials");
        }
        return UsernamePasswordAuthenticationToken.authenticated(
            name, null, AuthorityUtils.createAuthorityList("ROLE_USER"));
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

AuthenticationProviderのBean数と自動登録の結果(実測)

作ったプロバイダを @Bean で登録すると、Beanの数によって結果が大きく変わります。DBユーザーの alice と、プロバイダが担当する legacy-bob でBasic認証を送った結果は次のとおりです。

構成 alice(DB) legacy-bob 起動ログ
UserDetailsServiceのみ 200 401 INFO:UserDetailsServiceで構成
+AuthenticationProvider Bean 1つ 401 200 WARN:UserDetailsServiceを使わない
+AuthenticationProvider Bean 2つ 200 401 INFO:Providerを登録しない

自分で AuthenticationManager や DaoAuthenticationProvider を組んでいない場合、Beanが1つのときはそのプロバイダだけがグローバルの AuthenticationManager に入り、DBのユーザーはログインできなくなります。Beanが2つのときは Found 2 AuthenticationProvider beans ... Global Authentication Manager will not be configured with AuthenticationProviders というINFOログだけを出して、どちらのプロバイダも登録されません。WARNではないため見落としやすい点に注意が必要です。この分岐は Spring Security 7.1.2 の InitializeAuthenticationProviderBeanManagerConfigurer に実装されています。

DB認証と独自プロバイダを併用するなら、プロバイダはBeanにせず、SecurityFilterChain 側で明示的に追加します。

http.authenticationProvider(new LegacyApiAuthenticationProvider(client));
// UserDetailsService の Bean はそのまま残す

この構成で同じ2ユーザーを試すと、alice と legacy-bob がどちらも200、legacy-bob に誤ったパスワードを送ると401でした。プロバイダが null を返したユーザーは、UserDetailsService を使う照合へ回ります。ただし、この追加が効くのはフィルタチェーンでの認証(フォームログイン・Basic認証)だけです。前半のJSONログインAPIが使うグローバルの AuthenticationManager には入らず、検証でも legacy-bob はBasic認証で200、ログインAPIでは401でした。ログインAPIでも併用するなら、独自プロバイダと DaoAuthenticationProvider を並べた ProviderManager を自分でBeanにし、APIとチェーンの両方で使います。

スマホアプリ・API向けJWT認証の構成と選択条件

セッション認証とJWT認証の使い分け

同じドメインで配信するWebアプリなら、ここまでのセッション認証(HttpOnly Cookie)が第一候補です。サーバー側で即時にログアウトでき、トークンの保存場所に悩む必要もありません。JWTを選ぶのは、スマホアプリや複数のAPIサーバーのように、サーバー側のセッションを共有せずに署名だけで検証したい場合です。発行したJWTは有効期限まで取り消しにくいため、有効期限を短くします(下の例では15分)。JWTの構造と署名の仕組みはJWTとは?構造・署名検証の仕組みとセッション・OAuth/OIDCとの違いを実装視点で解説、Authorization ヘッダーの書き方はBearer Token(ベアラートークン)とは?読み方・Authorization: Bearerの書き方とJWTとの違いで解説しています。

Resource ServerとNimbusJwtEncoderによる発行・検証の実装

JJWTなどで検証フィルタを自作するより、spring-boot-starter-security-oauth2-resource-server(Spring Boot 4 での名前。旧名の spring-boot-starter-oauth2-resource-server は非推奨)を使うほうが短く書けます。公式のOAuth 2.0 Resource Server JWTのページにある NimbusJwtDecoder.withSecretKey で共通鍵(HS256)のトークンを検証し、発行には NimbusJwtEncoder.withSecretKey を使います。次の2つのチェーンは前半の securityFilterChain を置き換えるものです。tokenChain(/token 専用)と apiChain の2本は併用し、前半の securityFilterChain は削除します。apiChain と securityFilterChain はどちらも全リクエストに一致するため、残すと競合します。/token はスマホアプリやサーバー間通信など、ブラウザ以外のクライアントがID・パスワードを送る前提なのでCSRF保護を外しています。ブラウザのSPAには前半のセッション認証を使います。

@Configuration
public class JwtConfig {

    @Bean
    SecretKey jwtKey(@Value("${app.jwt.secret}") String base64) {   // 32バイト以上の乱数をBase64で
        return new SecretKeySpec(Base64.getDecoder().decode(base64), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder(SecretKey key) {
        return NimbusJwtEncoder.withSecretKey(key).build();
    }

    @Bean
    JwtDecoder jwtDecoder(SecretKey key) {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
        decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer("demo"));   // 発行者も検証する
        return decoder;
    }

    @Bean
    JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
        roles.setAuthoritiesClaimName("roles");   // roles クレームを権限として読む
        roles.setAuthorityPrefix("");             // 既定の SCOPE_ を付けない
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(roles);
        return converter;
    }

    @Bean @Order(1)
    SecurityFilterChain tokenChain(HttpSecurity http) throws Exception {
        http.securityMatcher("/token")
            .authorizeHttpRequests(a -> a.anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .csrf(c -> c.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }

    @Bean @Order(2)
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
            .csrf(c -> c.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
}

@RestController
public class TokenController {
    private final JwtEncoder encoder;

    public TokenController(JwtEncoder encoder) { this.encoder = encoder; }

    @PostMapping("/token")
    public Map<String, String> token(Authentication auth) {
        Instant now = Instant.now();
        List<String> roles = auth.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .filter(a -> a.startsWith("ROLE_"))   // FACTOR_PASSWORD を除く
            .toList();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("demo")
            .issuedAt(now)
            .expiresAt(now.plusSeconds(900))
            .subject(auth.getName())
            .claim("roles", roles)
            .build();
        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return Map.of("access_token",
            encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue());
    }
}

検証で確かめたJWTの拒否パターンとロールの扱い

Basic認証で /token を呼ぶと、{"alg":"HS256"} の15分有効なトークンが返り、Authorization: Bearer で送ると200でした。拒否されたパターンは次のとおりです。

送ったもの 結果 error_description(抜粋)
トークンなし 401 なし(resource_metadata のみ)
sub を admin に書き換え 401 Invalid signature
署名の途中を1文字変更 401 Invalid signature
alg を none にして署名なし 401 Unsupported algorithm of none
正しい鍵で署名し iss だけ別の値 401 The iss claim is not valid
USERのトークンでADMIN用API 403 insufficient_scope

鍵が32バイト(256ビット)未満だと、アプリは起動時に KeyLengthException: The secret length must be at least 256 bits で止まります。既定の変換は scope(または scp)クレームだけを読み、値に SCOPE_ を付けて権限にします。このため JwtAuthenticationConverter を定義しないと、掲載例の roles クレームは無視されてロール権限が付きません。scope にロール名を入れた場合も、検証では SCOPE_ROLE_USER となり hasRole と一致しませんでした。上のように roles クレームを接頭辞なしで読ませると、ADMINのトークンで200、USERのトークンで403になりました。ReactなどのSPAと組み合わせる場合のCORS設定はSpring BootとReactの連携方法|REST API・CORS・JWT認証から1つのjarへの同梱まで【Spring Boot 4.1】を参照してください。

MockMvcによる認証成功・失敗と権限不足のテスト

spring-boot-starter-security-test と spring-boot-starter-webmvc-test を test スコープで追加すると、認証まわりをサーバーを起動せずにテストできます。Spring Boot 4 では @AutoConfigureMockMvc のパッケージが org.springframework.boot.webmvc.test.autoconfigure に移っているため、3系のimport文のままではコンパイルできません。次の5件は Spring Boot 4.1.2 ですべて成功しました。前提は、この記事の SecurityConfig・DbUserDetailsService に加え、/api/me(ログイン中のユーザー名を返す)と @PreAuthorize("hasRole('ADMIN')") を付けた /api/admin のコントローラーがあり、起動時に alice(USER)を登録していることです。

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;

@SpringBootTest
@AutoConfigureMockMvc
class AuthTests {
    @Autowired MockMvc mvc;

    @Test
    void formLoginSucceeds() throws Exception {
        mvc.perform(formLogin().user("alice").password("password"))
           .andExpect(authenticated().withUsername("alice"));
    }

    @Test
    void formLoginFailsWithWrongPassword() throws Exception {
        mvc.perform(formLogin().user("alice").password("wrong"))
           .andExpect(unauthenticated());
    }

    @Test
    void basicAuthReturns200() throws Exception {
        mvc.perform(get("/api/me").with(httpBasic("alice", "password")))
           .andExpect(status().isOk());
    }

    @Test
    void anonymousApiReturns401() throws Exception {
        mvc.perform(get("/api/me")).andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "USER")
    void userCannotCallAdmin() throws Exception {
        mvc.perform(get("/api/admin")).andExpect(status().isForbidden());
    }
}

テストデータの alice は、起動時に PasswordEncoder でハッシュ化して登録しています。平文のまま data.sql に書くと、{bcrypt} などのIDが無いため照合で例外になります。

認証をSpring Securityで自作するか認証基盤に任せるかの判断基準

ここまでの実装で済むのは、1つのアプリに閉じたID・パスワード認証です。次の条件に1つでも当てはまるなら、自作を広げずに Keycloak・Auth0・Amazon Cognito などの認証基盤を立て、Spring Boot 側は OAuth2 Login(oauth2Login())か Resource Server に徹するほうが安全です。

  • 複数のアプリでログインを共有する(シングルサインオン)
  • 多要素認証やパスキー、Google・Microsoftアカウントでのログインが要件にある
  • パスワードリセットやアカウントロックの運用を自前で持ちたくない

この記事の構成で自作する場合も、ログイン試行回数の制限・パスワード再設定・監査ログは別途実装が必要です。認証基盤との連携で使う仕組みはOAuthとは?OAuth 2.0の仕組み・認可フローと認証との違い、OAuth 2.1の変更点をわかりやすく解説とOIDC(OpenID Connect)とは?IDトークンの検証手順とOAuth 2.0との違いを解説で解説しています。認証基盤の選定や既存システムからの移行まで含めて検討する場合は、認証基盤・ID管理システム開発の支援内容もご覧ください。

よくある質問

Spring Bootのデフォルトのログインパスワードはどこで確認できますか?

起動ログの Using generated security password: の行に出力されます。ユーザー名は user です。UserDetailsService のBeanを定義すると生成されなくなります。

WebSecurityConfigurerAdapterが使えないのはなぜですか?

Spring Security 6.0 で削除されたためです。Spring Boot 3 以降は SecurityFilterChain を返すBeanで設定し、authorizeHttpRequests と requestMatchers をラムダで書きます。

ログインAPIで認証できたのに次のリクエストが401になるのはなぜですか?

最初に確認するのは SecurityContextRepository への保存です。Spring Security 6 以降は、SecurityContextHolder に入れるだけではセッションに残らないため、saveContext(context, request, response) を呼んでください。保存しているのに401になる場合は、クライアントが JSESSIONID Cookie を送っているか(fetch の credentials 設定など)と、セッションが失効していないかを確認します。

カスタムAuthenticationProviderを登録したらDB認証が効かなくなったのはなぜですか?

AuthenticationProvider のBeanが1つだけあると、それだけがグローバルの AuthenticationManager に入り、UserDetailsService は使われなくなります。プロバイダはBeanにせず http.authenticationProvider(...) で追加してください。

SPA(ReactやVue)ではセッションとJWTのどちらを使うべきですか?

同じドメインから配信するならHttpOnly Cookieのセッション認証が扱いやすく、ログアウトも即時に効きます。スマホアプリや複数のAPIサーバーで、セッションを共有せずに署名だけで検証したい場合はJWTを選び、有効期限を短くしたうえで OAuth2 Resource Server で検証します。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.03 テックブログ ノジマ不正アクセスで約700万件漏えい:対象確認・窓口と乗っ取り注文を止める実装
  2. 2026.10.29 テックブログ ソニー生命の情報漏洩38.5万件|ログインできない理由、再開状況と窓口の電話番号
  3. 2026.11.03 テックブログ クラブツーリズム不正アクセス:約432万件の対象確認と偽メールの見分け方・流出検知の設計
  4. 2026.11.02 テックブログ アララの不正アクセス:メール約7,700万件の持ち出しと契約企業が行うAPIキー再設定・代替送信・連名報告
  5. 2026.11.02 テックブログ サントリーの不正アクセスで約10万人分漏えい|M365のファイル大量ダウンロードを監査ログで調べる手順

RELATED POSTS 関連記事

目次