---
title: "Spring Bootの認証実装｜DB認証・ログインAPI・remember-me・JWTの書き方【Spring Security 7.1】"
url: "https://www.issoh.co.jp/tech/details/2467/"
published: 2024-06-12
updated: 2026-10-26
categories: ["Spring"]
publisher: "株式会社一創"
---

# 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で解説](/tech/details/2465/)にまとめているので、この記事では実装に絞ります。Spring Boot の各版は、依存関係管理によって既定の Spring Security の版を指定しています。明示的に上書きしている場合は、実際に解決された依存バージョンを確認してください。[Spring Bootのサポート表](https://spring.io/projects/spring-boot#support)（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月末終了](/tech/details/5172/)にまとめています。

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

検索で見つかる認証のサンプルには 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月】](/tech/details/10124/)で扱っています。

## 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対応】](/tech/details/7001/)を参照してください。

```
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のエラー別に切り分け](/tech/details/3031/)で解説しています。

### 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の説明](https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html)にあるとおり BouncyCastle（`org.bouncycastle:bcprov-jdk18on`）を別途必要とします。`defaultsForSpringSecurity_v5_8()` の既定値はメモリ16MiB・反復2回・並列度1で、[OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password%5FStorage%5FCheat%5FSheet.html)が挙げる最小構成の一例（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のページ](https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html)にも、リクエスト間で認証状態を保持するには `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の発行から失効までの実装と保存先選定を解説](/tech/details/16366/)で詳しく説明しています。

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

ブラウザを閉じてもログイン状態を残すには remember-me を使います。トークンをDBに保存する方式（`PersistentTokenRepository`）なら、サーバー側でトークンを失効できます。テーブル定義は[公式のRemember-Meのページ](https://docs.spring.io/spring-security/reference/servlet/authentication/rememberme.html)に載っている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](https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html)のとおり、`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](https://github.com/spring-projects/spring-security/blob/7.1.2/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeAuthenticationProviderBeanManagerConfigurer.java) に実装されています。

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との違いを実装視点で解説](/tech/details/13453/)、`Authorization` ヘッダーの書き方は[Bearer Token（ベアラートークン）とは？読み方・Authorization: Bearerの書き方とJWTとの違い](/tech/details/4150/)で解説しています。

### 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のページ](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)にある `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】](/tech/details/1542/)を参照してください。

## 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の変更点をわかりやすく解説](/tech/details/8369/)と[OIDC（OpenID Connect）とは？IDトークンの検証手順とOAuth 2.0との違いを解説](/tech/details/6532/)で解説しています。認証基盤の選定や既存システムからの移行まで含めて検討する場合は、[認証基盤・ID管理システム開発](/service/system/identity/)の支援内容もご覧ください。

## よくある質問

### 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 で検証します。

## 関連記事

- [Spring Securityとは？認証・認可の仕組みと基本設定を7.1で解説](/tech/details/2465/)
- [SecurityFilterChainとは？Spring Securityのフィルタ実行順序と設定例【Spring Security 7.1対応】](/tech/details/7001/)
- [Spring BootとReactの連携方法｜REST API・CORS・JWT認証から1つのjarへの同梱まで【Spring Boot 4.1】](/tech/details/1542/)
- [JWTとは？構造・署名検証の仕組みとセッション・OAuth/OIDCとの違いを実装視点で解説](/tech/details/13453/)
- [セッション管理とは？セッションIDの発行から失効までの実装と保存先選定を解説](/tech/details/16366/)

---

出典: [Spring Bootの認証実装｜DB認証・ログインAPI・remember-me・JWTの書き方【Spring Security 7.1】](<https://www.issoh.co.jp/tech/details/2467/>)（株式会社一創）
