初めてSpring Securityを導入したら、突然ログイン画面が出てきて混乱した。そんな経験をした開発者は少なくないでしょう。

この記事では、Spring Securityを初めて使う開発者向けに、認証機能の基本を段階的に解説します。最小構成から始めて、Basic認証、フォーム認証へと順を追って実装しながら、各設定の意味とつまずきやすいポイントを説明していきましょう。

Spring Securityとは

Spring Securityは、Springアプリケーションの 認証(誰か)認可(何ができるか) を担うフレームワークです。主な認証方式にはBasic認証、フォーム認証、OAuth2、JWTなどがあり、この記事ではBasic認証とフォーム認証を扱います。

Spring Securityのデフォルト動作を確認する

まずはデフォルト動作の確認からです。pom.xmlに以下の依存関係を追加するだけで、自動的に全エンドポイントが保護されます。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

起動すると、コンソールに次のログが表示されます。

Using generated security password: 8e557245-73e2-4286-969a-ff57fe326336

このパスワードは 起動ごとに変わります。デフォルトユーザー名のuserと組み合わせてログインでき、ブラウザでアクセスすると自動生成されたログインページが表示されます。

このデフォルトパスワードは開発時のみ使用してください。固定したい場合はapplication.ymlで以下のように設定できますが、本番環境では必ず無効化する必要があります。

spring:
  security:
    user:
      name: user
      password: dev-password

この自動設定の背後にあるのが、次に見る SecurityFilterChain という仕組みです。

SecurityFilterChainの基本

Spring Securityの中核となるのが SecurityFilterChain です。どのURLを保護するか、どの認証方式を使うかといったセキュリティルールを定義します。Spring Security 6以降では、SecurityFilterChain@Beanとして登録し、Lambda DSL記法で設定するのが標準です。

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.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .httpBasic(withDefaults());  // この時点で認証方式を指定
        return http.build();
    }
}

@Configurationを付けた設定クラスでSecurityFilterChain@Bean登録すると、Spring Bootがこれを自動検出し、アプリケーション全体のセキュリティ設定として適用します。DIの実践的な応用例でもあります。.httpBasic(withDefaults())の部分で認証方式としてBasic認証を指定しています。なおwithDefaults()httpBasic()と機能上同じで、デフォルト設定のまま有効化することを明示するSpring Security 6以降の推奨スタイルです。

古い記事でよく見るWebSecurityConfigurerAdapterの継承は、Spring Security 5.7で非推奨、6.0(Spring Boot 3.0)で削除されました。configure(HttpSecurity)のオーバーライドはSecurityFilterChain@Beanに、ユーザー登録はUserDetailsService@Beanに置き換え、antMatchers()requestMatchers()に書き換えてください。

Basic認証の最小実装

それでは、InMemoryユーザーを使ったBasic認証を実装してみましょう。

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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .httpBasic(withDefaults());
        return http.build();
    }

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

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails user = User.builder()
            .username("user")
            .password(passwordEncoder.encode("password"))
            .roles("USER")
            .build();

        return new InMemoryUserDetailsManager(user);
    }
}

UserDetailsServiceのBeanでは、InMemoryUserDetailsManagerを使ってメモリ上にテストユーザーを作成しています。最初からBCryptPasswordEncoderを使っている点に注意してください。古いコード例のUser.withDefaultPasswordEncoder()Spring Security 5.7以降で非推奨(deprecated) で、開発環境でも使うべきではありません。

Basic認証では、ユーザー名とパスワードをBase64エンコードしてAuthorizationヘッダーで送信します。

curl -u user:password http://localhost:8080/api/hello

# ヘッダーを直接指定する場合(user:password を Base64 エンコードした値)
curl -H "Authorization: Basic dXNlcjpwYXNzd29yZA==" http://localhost:8080/api/hello

ブラウザでアクセスすると標準の認証ダイアログが表示されます。注意したいのは、Base64はエンコード(可逆な符号化)であって暗号化ではない点です。HTTPのままでは盗聴で容易にパスワードを復元されるため、本番では必ずHTTPS(TLS)とセットで使ってください。

パスワードエンコーダーの重要性

パスワードの平文保存は、データベース漏洩時に全ユーザーのパスワードが露出する重大な問題です。PasswordEncoder@Beanとして登録すると、Spring Securityが認証時に自動的にこのBeanを使ってパスワードを検証してくれます。BCryptPasswordEncoderでエンコードされたパスワードは$2a$10$...のような形式で、バージョン、ソルト、ハッシュ値を含んでいます。

spring-security-cryptoモジュールとDelegatingPasswordEncoder

BCryptPasswordEncoderなどのエンコーダーは spring-security-crypto という独立したモジュールに含まれています。パスワードのハッシュ化だけ使いたいバッチや管理ツールでは、このモジュールだけを依存に追加できます(バージョンはSpring BootのBOMで解決されるため<version>指定は不要です)。

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-crypto</artifactId>
</dependency>

また、実務ではBCryptPasswordEncoderを直接使うより、DelegatingPasswordEncoder を使うのが推奨されています。

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

このエンコーダーは、ハッシュ文字列の先頭に{bcrypt}のような エンコーダーID を付けて保存し(例: {bcrypt}$2a$10$...)、検証時はIDに応じたエンコーダーに委譲します。将来より強いアルゴリズム({argon2}など)に移行しても既存ユーザーのパスワードを検証し続けられ、デフォルトはBCryptなので本記事のサンプルからの置き換えでも挙動は変わりません。

BCryptの計算コスト(strength)はコンストラクタで指定でき、デフォルトは10、値を1つ上げるごとに計算時間が約2倍になります。本番では10〜12程度が一般的です。なお、BCryptの計算が遅く感じても、NoOpPasswordEncoderは非推奨かつ危険なので使わないでください。

フォーム認証への移行

Basic認証のブラウザダイアログは使い勝手がよくないため、一般ユーザー向けのWebアプリケーションではフォーム認証がより適しています。先ほどのSecurityFilterChainを次のように変更します(PasswordEncoderUserDetailsServiceのBeanは前節と同じです)。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated()
        )
        .formLogin(form -> form
            .defaultSuccessUrl("/home", true)
            .permitAll()
        );
    return http.build();
}

.httpBasic(withDefaults()).formLogin()に置き換えました。defaultSuccessUrl("/home", true)でログイン成功時のリダイレクト先を指定し、.permitAll()でログインページ自体へのアクセスを許可しています(これがないと無限リダイレクトが発生します)。デフォルトのログインページは/loginで自動生成されます。

ログインページのカスタマイズ

独自のログインページを使う場合は、以下のように設定します。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/custom-login", "/css/**", "/js/**").permitAll()
            .anyRequest().authenticated()
        )
        .formLogin(form -> form
            .loginPage("/custom-login")
            .defaultSuccessUrl("/home", true)
            .permitAll()
        )
        .logout(logout -> logout
            .logoutSuccessUrl("/custom-login?logout")
            .permitAll()
        );
    return http.build();
}

.loginPage("/custom-login")でカスタムログインページを指定し、.requestMatchers()で静的リソースとログインページへのアクセスを許可しています。.requestMatchers("/css/**")**はAnt形式のパターンマッチングで、「/css/以下の全パス」を意味します。

.logout()はログアウト機能の設定で、logoutSuccessUrl("/custom-login?logout")によりログアウト後はログインページに戻り、?logoutパラメータで成功メッセージを表示できます。

Thymeleafでのログインページの例です(src/main/resources/templates/custom-login.htmlに配置)。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ログイン</title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <div class="login-container">
        <h1>ログイン</h1>
        
        <div th:if="${param.error}" class="error">
            ユーザー名またはパスワードが正しくありません。
        </div>
        
        <div th:if="${param.logout}" class="success">
            ログアウトしました。
        </div>
        
        <form th:action="@{/custom-login}" method="post">
            <div>
                <label for="username">ユーザー名:</label>
                <input type="text" id="username" name="username" required>
            </div>
            <div>
                <label for="password">パスワード:</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit">ログイン</button>
        </form>
    </div>
</body>
</html>

ポイントは、action属性をth:actionで指定すること、method="post"が必須なこと、name属性がデフォルトでusernamepasswordである必要があることです。${param.error}でログインエラーのメッセージ表示もできます。th:actionを使ったPOSTフォームにはCSRFトークンの隠しフィールドが自動で埋め込まれるため、手動追加は不要です。CSSファイルはsrc/main/resources/static/css/style.cssに置けば、静的リソースとして自動的に公開されます。

HttpSecurityの主な設定項目を俯瞰する

HttpSecurityで何が設定できるのか、よく使う項目を1つのSecurityFilterChainにまとめると次の通りです(設定メソッド部分の抜粋で、HttpMethodSessionCreationPolicyのimportは省略しています)。

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        // 1. URLごとの認可ルール(上から順に評価される)
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
            .anyRequest().authenticated()
        )
        // 2. 認証方式(複数指定可)
        .formLogin(form -> form
            .loginPage("/login")
            .defaultSuccessUrl("/home", true)
            .permitAll()
        )
        .httpBasic(withDefaults())
        // 3. ログアウト
        .logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/login?logout")
            .invalidateHttpSession(true)
            .deleteCookies("JSESSIONID")
        )
        // 4. CSRF(REST APIで無効化する場合。Webアプリでは有効のままにする)
        .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
        // 5. セッション管理
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
            .maximumSessions(1)
        )
        // 6. 未認証(401/リダイレクト)・権限不足(403)時の応答
        .exceptionHandling(ex -> ex
            .accessDeniedPage("/access-denied")
        )
        // 7. セキュリティヘッダー(H2コンソールをiframe表示したい場合など)
        .headers(headers -> headers
            .frameOptions(frame -> frame.sameOrigin())
        );
    return http.build();
}

各項目の役割を整理します。

設定メソッド役割補足
authorizeHttpRequests()URLパターンごとの認可ルール上から順に評価されるため、狭いパターンを先に書く
formLogin() / httpBasic()認証方式両方書けば併用可能
logout()ログアウト時のセッション破棄とリダイレクトデフォルトのログアウトURLはPOST /logout
csrf()CSRFトークン検証デフォルト有効。ステートレスなREST APIのみ無効化を検討
sessionManagement()セッション生成ポリシー・同時ログイン数JWTなどステートレス認証ではSTATELESS
exceptionHandling()未認証時のAuthenticationEntryPoint・権限不足時のAccessDeniedHandlerREST APIでは401/403をJSONで返す用途に使う
headers()X-Frame-Optionsなどのセキュリティヘッダーデフォルトで安全側の設定が付与される
cors()CORS設定フロントエンドを別オリジンで動かす場合に必要

csrf()の扱いはSpring SecurityのCSRF対策を正しく理解する - REST APIとWebアプリでの設定の違いcors()Spring BootでCORSを設定する方法で詳しく解説しています。

REST APIとWeb画面でSecurityFilterChainを分ける

実務では「/api/**はBasic認証(またはJWT)でステートレス、それ以外はフォーム認証」のように要件が分かれることがよくあります。この場合はSecurityFilterChain@Beanを複数定義し、securityMatcher()で担当するURLを限定したうえで@Orderで評価順を指定します。

@Configuration
public class SecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")   // このチェーンは /api/** のみ担当
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .httpBasic(withDefaults())
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/css/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form.loginPage("/login").permitAll());
        return http.build();
    }
}

@Orderの値が小さいチェーンから順にマッチが試され、最初にマッチしたチェーンだけが適用されます。securityMatcher()のないチェーンは全リクエストにマッチするため、必ず最後に配置してください。順序を誤ると「/api/**にアクセスしたのにログイン画面へリダイレクトされる」典型的なトラブルになります。

認証処理はどう流れているのか

フォーム認証・Basic認証のいずれも、内部では次の流れで処理されます。

  1. 認証フィルター(UsernamePasswordAuthenticationFilter / BasicAuthenticationFilter)がリクエストから認証情報を取り出し、未認証のAuthenticationを作る
  2. AuthenticationManager(実装はProviderManager)が、登録されたAuthenticationProviderに順番に委譲する
  3. DaoAuthenticationProviderUserDetailsService#loadUserByUsername()でユーザーを取得し、PasswordEncoder#matches()でパスワードを照合する
  4. 成功すると認証済みのAuthenticationSecurityContextHolderに格納され、以降は@AuthenticationPrincipalなどで参照できる
  5. 失敗するとAuthenticationExceptionが投げられ、Basic認証では401、フォーム認証では/login?errorへのリダイレクトが返る

この記事でUserDetailsServicePasswordEncoder@Bean登録しただけで認証が動いたのは、自動設定がこれらのBeanを検出してDaoAuthenticationProviderに組み込んでくれるためです。DBからユーザーを取得したい場合もこのUserDetailsServiceを差し替えるだけで、手順はSpring SecurityでDB認証を実装する - UserDetailsServiceとJdbcUserDetailsManagerで解説しています。

未認証のまま保護リソースにアクセスされたときの応答を決めるのがAuthenticationEntryPointです。httpBasic()では401とWWW-Authenticate: Basicヘッダー(これを見てブラウザは認証ダイアログを出します)、formLogin()では/loginへの302リダイレクトが返ります。REST APIでJSONのエラーボディを返したい場合は、独自のAuthenticationEntryPointを設定します(HttpServletResponsejakarta.servlet.httpパッケージのものをimportします)。

http
    .httpBasic(basic -> basic.realmName("my-api"))
    .exceptionHandling(ex -> ex
        .authenticationEntryPoint((request, response, authException) -> {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write("{\"error\":\"unauthorized\"}");
        })
    );

初心者がつまずきやすい設定エラーと解決法

使い始めに遭遇しやすい典型的なエラーを紹介します。

1. “There is no PasswordEncoder mapped for the id “null"" エラー

パスワードエンコーダーを設定せずに平文パスワードを使おうとすると発生します。PasswordEncoder@Beanとして登録し、パスワードをエンコードしてください。

2. ログインページへの無限リダイレクト

ログインページ自体が認証を要求すると無限リダイレクトが発生します。.formLogin()内で.permitAll()を呼び出し、さらに.requestMatchers("/custom-login").permitAll()で明示的に許可してください。

3. CSRFトークンエラー

Thymeleafのth:actionを使えば自動的にCSRFトークンが埋め込まれます。手動でHTMLを書く場合は、<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>を追加してください。

デバッグログの有効化

トラブル調査には、内部動作を詳細に出力するデバッグログが役立ちます。application.ymlに以下を追加してください。

logging:
  level:
    org.springframework.security: DEBUG

認証方式の選択基準

Basic認証とフォーム認証、どちらを選ぶべきでしょうか。Basic認証が適しているのは、REST APIのエンドポイント保護、簡易的な管理画面や開発用ツール、curlやPostmanなどツールからのアクセスが主なステートレスアプリケーションです。一方、ブラウザからアクセスする一般ユーザー向けWebアプリケーションで、ログイン画面のカスタマイズやセッション管理が必要なら、フォーム認証を選びましょう。

なお、httpBasic()formLogin()は同じチェーン内で併用でき、ブラウザにはフォーム認証が、Authorization: Basic ...ヘッダー付きリクエストにはBasic認証が応答します。ただし混乱を招きやすいので、先ほど紹介したようにREST API用とWebUI用でチェーンを分けるのが無難です。

Spring BootのProfilesを使って環境によって設定を切り替えるようにして、開発環境はBasic認証、本番環境はフォーム認証と使い分けることもできますよ。

次のステップ

まずはこの記事で実装した基本を自分のプロジェクトで試したうえで、要件に応じて次の記事へ進んでみてください。