NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

注目のタグ

    【初心者がやってみた】ちょっと本気のSpring Security

    本記事は  ブログ書き初めウィーク  5日目の記事です。
    📝  4日目  ▶▶ 本記事 ▶▶  6日目  📅

    はじめに

    はじめまして。入社1年目の藤本と申します。
    年末年始、みなさんいかがお過ごしだったでしょうか?
    筆者は初詣で引いたおみくじにて、「茨道を耐えなさい」と書いてありました。何が起こるんだ2026は。

    昨年4月に入社した筆者は、入社後の研修にて「防御は最大の攻撃」という言葉に惹かれ、セキュリティ分野に興味を持ちました。
    その中で、Webアプリケーションのセキュリティフレームワークである Spring Security について学んだため、備忘録程度にはなりますがまとめてみました。

    本記事は、Spring Securityを「なんとなく」から、「意図して扱える」ようになることを目的としています。最小構成から少しずつステップアップしながら仕組みを理解していきます。

    「これからSpring Securityを学んでみたい!!」といった方に、ぜひ読んでいただけるとうれしいです。

    Spring Securityとは

    Spring Securityは、Webアプリケーションフレームワーク Spring Boot のデファクトスタンダードなセキュリティフレームワークです。
    認証(ユーザーが本人であるかを確認)・認可(ユーザーのアクセス権を確認)・CSRF 対策・パスワード管理など、Webアプリケーションによく利用されるセキュリティ機能を提供します。

    実際にWebアプリケーションで発生するセキュリティ問題の例として、次のようなケースがあります。

    • 認証機能が甘いと、不正なアカウントから簡単に侵入されてしまう
    • 権限チェックが不十分だと、一般ユーザーが管理画面にアクセスできてしまう
    • APIの保護が弱いと、外部から勝手にデータを操作されてしまう

    こうした問題は開発するアプリケーションの規模に関係なく発生する可能性が常にあります。
    Spring Securityを用いることで複雑なコードを書くことなく、最新のセキュリティをWebアプリケーション上に実現することが可能です。
    ただし、設定の学習コストはあるため、仕組みを理解して意図的に使うことが重要です。

    なぜSpring Securityを理解する必要があるのか?

    前述したように、Spring SecurityはWebアプリケーション上にセキュリティを実現する上で欠かせない存在ですが、その便利さゆえ、初学者にとっては理解するまでの敷居が少々高く感じられます。

    実際に1行程度の依存関係を追加してみると、下のようなログイン画面が出てきます。
    またアプリ全体が保護され、コンソールには自動生成されたパスワードが出力されます。
    ※後述するStep 1にて実際に再現して確認します。

    【やせいの ログイン画面が あらわれた!】

    この、 " 何故かよく分からないけど何となくできている感 " に戸惑う開発者は少なくありません。

    筆者自身もSpring Securityに興味を持ち始めた当初、「Spring Security」とネット検索し、表示されたサイトの手順通りにやってみると、あっという間にそれっぽいものができました。
    しかし、実際はどの部分が何の処理を担当しているのか分からない状態でした。

    だからこそ、Spring Securityの仕組みを理解し、意図的に扱えるようになることが重要です。

    この記事で目指すこと

    本記事では、以下の5ステップでSpring SecurityをWebアプリケーション上に実装していきます。

    ※本記事はまず仕組みをつかむための学習用デモです。実運用においてはHTTPS必須、ハッシュ化パスワード、CSRF保護など適切な認証方式に置き換えてください。

    Step 1:最小構成を動かしてみる

    【Step 1のゴール】

    • ログインが必要なサイトにアクセスしたときの自動ログイン画面を体験できる

    【やること】

    • 学習用プロジェクトの環境構築を行う

    • spring-boot-starter-securityを追加して起動する

    • 自動生成のユーザー(user)とコンソール出力パスワードでログインする

    Step 2:SecurityConfigを実装してみる

    【Step 2のゴール】

    • ECサイトの「商品一覧は誰でも見れるけど、購入はログイン必須」のような制御を作る

    【やること】

    • SecurityFilterChainを自作し、Spring Securityの基本設定を体験する

    • authorizeHttpRequests()で、permitAll / authenticated / hasRoleの違いを理解する

    Step 3:ユーザーとロールを定義する

    【Step 3のゴール】

    • オリジナルのユーザーを作り、管理者ページのような一部の人しか見れない画面を作る

    【やること】

    • InMemoryUserDetailsManagerを使って、ADMINとUSERの2種類のユーザーを作成する

    • ロールに応じてアクセス制御が正しく動くことを確認する(ADMINをもったユーザーだけが管理者ページに入れる)

    Step 4:メソッドレベルの認可を実現する

    【Step 4のゴール】

    • 画面やURLではなく、Webアプリケーションの内部からアクセス制御を行う

    【やること】

    • @EnableMethodSecurity + @PreAuthorizeでメソッド単位での実行制御を実装する

    • Controllerのメソッドにロール制御を付け、権限の有無でユーザーが内部処理を実行可能・不可能にする

    Step 5:401 / 403のハンドリングを実装する

    【Step 5のゴール】

    • ログインしていないのに会員ページにアクセスすると「ログインしてください」が表示される

    • 権限がないページにアクセスすると「アクセスできません」が表示される

    【やること】

    • AuthenticationEntryPoint(401)と AccessDeniedHandler(403)を実装する

    • /api/** は JSON、Web は HTML を返却し、API と Web で異なるエラーレスポンスを返す方法を学ぶ

    Step 1:最小構成を動かしてみる

    開発環境

    本記事内で使用している開発環境は以下の通りです。

    項目   バージョン
    IDE IntelliJ IDEA 2025.2 (Community Edition)
    ビルドツール Gradle 9.2.1              
    フレームワーク Spring Boot 4.0.0      
    言語 Java 21.0      

    環境構築

    Spring Securityを実装するためのWebアプリケーション、Spring Boot環境をまずは用意する必要があります。

    本記事では、Spring Initializrを使い、テンプレートを作成します。
    Spring Initializrとは、Spring公式が提供しているプロジェクト生成ツールで、必要な設定やライブラリを選ぶだけで、Spring Bootプロジェクトの初期構成を自動生成してくれます。

    ※使用するバージョンによって、記法や各種項目の噛み合いが悪いことがあります。この辺りは何度かテンプレートを作成しながら調整してみてください。

    Spring Initializrの作成画面

    各種項目を設定し、テンプレートを作成(Generate)すると、ZIPファイルが作られるため、使用するIDE(本記事ではIntelliJ)でこれをインポートします。

    src/main/java/配下にあるjavaファイル(本記事ではDemoApplication.java)を実行し、以下のログ(プロセスは終了コード 0 で完了しました)が出力されていれば、Spring Bootによるプロジェクトが正常に起動している状態となります。

    PS C:\work\demo> ./gradlew bootRun                                             
    
    > Task :bootRun
    
      .   ____          _            __ _ _
     /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
    ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
     \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
      '  |____| .__|_| |_|_| |_\__, | / / / /
     =========|_|==============|___/=/_/_/_/
    
     :: Spring Boot ::                (v4.0.0)
    
    2026-01-07T18:11:59.726+09:00  INFO 5900 --- [demo] [  restartedMain] com.example.demo.DemoApplication         : Starting DemoApplication using Java 21.0.8 with PID 5900 (C:\work\demo\build\classes\java\main started by fujimoto in C:\work\demo)
    2026-01-07T18:11:59.726+09:00  INFO 5900 --- [demo] [  restartedMain] com.example.demo.DemoApplication         : No active profile set, falling back to 1 default profile: "default"
    2026-01-07T18:11:59.774+09:00  INFO 5900 --- [demo] [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
    2026-01-07T18:11:59.774+09:00  INFO 5900 --- [demo] [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
    2026-01-07T18:12:00.916+09:00  INFO 5900 --- [demo] [  restartedMain] o.s.boot.tomcat.TomcatWebServer          : Tomcat initialized with port 8080 (http)
    2026-01-07T18:12:00.928+09:00  INFO 5900 --- [demo] [  restartedMain] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
    2026-01-07T18:12:00.928+09:00  INFO 5900 --- [demo] [  restartedMain] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/11.0.14]
    2026-01-07T18:12:00.959+09:00  INFO 5900 --- [demo] [  restartedMain] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 1185 ms
    2026-01-07T18:12:01.273+09:00  INFO 5900 --- [demo] [  restartedMain] r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name userDetailsService
    2026-01-07T18:12:01.577+09:00  INFO 5900 --- [demo] [  restartedMain] o.s.b.w.a.WelcomePageHandlerMapping      : Adding welcome page: class path resource [static/index.html]
    2026-01-07T18:12:01.875+09:00  INFO 5900 --- [demo] [  restartedMain] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8080 (http) with context path '/'
    2026-01-07T18:12:01.875+09:00  INFO 5900 --- [demo] [  restartedMain] com.example.demo.DemoApplication         : Started DemoApplication in 2.509 seconds (process running for 2.917)
    
    
    プロセスは終了コード 0 で完了しました

    ※筆者はここでネットワーク(プロキシ)周りのエラーが発生しました。
    IntelliJ:「ファイル」>「設定」>「外観&振る舞い」>「システム設定」>「HTTPプロキシ」よりプロキシ設定を見直してみると解決するかと思います。
    参考: tech.nri-net.com

    Spring Securityを導入してみる

    Spring Securityを使うため、build.gradle(Gradleの場合)に以下のコードを追記します。

    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-security'
    }
    上のコードで、Spring SecurityをGradleの依存関係として定義(=Spring Securityを使いたい!と宣言するイメージ)します。これにより、Spring Bootプロジェクト内にSpring Securityを導入することができます。

    実際にこの状態で、アプリ(本記事ではDemoApplication.java)を再度起動し、その後ブラウザでhttp://localhost:8080を叩いてみます。

    ログイン画面

    すると、作った記憶のないログインページが出てきました。
    このログインページ、実はSpring Securityのデフォルト機能となっており、ログインページを突破しないと他ページが見れない(=何もしなくても全ページが保護された状態)というセキュリティが実現している訳です。もう既にすごくないか。

    また、このログインページのユーザー名とパスワードについては、初期設定では下記のユーザー名・パスワードを使ってログインします。(ログイン後の遷移先ページは今時点でまだ作成していないので、とりあえずはログイン画面で弾かれなければOKです)

    • ユーザー名: user
    • パスワード: 下のようにコンソールに出力された自動生成パスワード
    Using generated security password: e99fe439-0539-4f8a-996e-414b2d74666c

    つまり、Spring Securityを依存関係として定義(=Spring Securityを導入)すると、ログインページなどの機能はデフォルトの機能として既に用意されており、この認証・認可の範囲やユーザー情報などの設定をよりカスタマイズしていくことがSpring Securityとしての進め方になります。

    以降のステップでは、詳細な保護範囲などを設定していき、より扱いやすいSpring Securityを目指していきます。

    Step 2:SecurityConfigを実装してみる

    SecurityConfigとは

    Spring Securityで制御するセキュリティの設定クラスにあたるのが、SecurityConfigになります。

    Step 1で定義したspring-boot-starter-securityの状態では、全ページが認証必須・自動生成パスワードが必要でした。
    しかし、実際のWebアプリケーションを考えてみると、

    • 自分で作ったユーザー名・パスワードを使ってログインしたい!
    • 全てのユーザーが見れるページ、ログインしていないと見れないページなど、ページごとに閲覧可能なユーザーを分けたい!

    といった機能が必要になるかと思います。

    こうした、認証・認可機能やユーザー情報の定義を設定することをSecurityConfigによって達成できます。

    SecurityConfigで認証・認可機能を実装する

    SecurityConfigクラスで認証・認可機能を実装する場合、SecurityFilterChainオブジェクトを自作する必要があります。
    spring-boot-starter-securityを定義すると、デフォルトの設定値が入ったSecurityFilterChainが自動的に作成されています。これを開発するWebアプリケーションのセキュリティ要項に合わせて、SecurityConfigクラス内のSecurityFilterChainオブジェクトにて定義するイメージです。
    また、SecurityFilterChainを定義するための宣言として、@EnableWebSecurityを付与します。

    • @EnableWebSecurity
      Webセキュリティを有効化するアノテーション。

    具体的に、以下のコードで定義することができます。

    SecurityConfig(Step 1~Step 2)
    @Configuration         // 設定ファイルであることを示すアノテーション
    @EnableWebSecurity     // Web 単位での制御を有効化(Step 2)
    public class SecurityConfig {
    
        public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
    
            http
                    // 認可(Authorization)(Step 2)
                    .authorizeHttpRequests(auth -> auth
                            // 誰でもアクセス可能
                            .requestMatchers("/", "/public/**").permitAll()
                            // ログインしていればアクセス可能
                            .requestMatchers("/user/**").authenticated()
                            // ADMIN を持つユーザーのみアクセス可能
                            .requestMatchers("/admin/**").hasRole("ADMIN")
                            .anyRequest().authenticated()
                    )
    
                    // 認証(Authentication)(Step 2)
                    // Spring Security のデフォルトログインページを使う場合
                    .formLogin(form -> form
                            .permitAll()
                    )
                    .logout(logout -> logout
                            .logoutSuccessUrl("/")
                            .permitAll()
                    );
    
            return http.build();
        }
    }
    
    • permitAll:誰でもアクセス可能
    • authenticated:ログインしていればアクセス可能
    • hasRole:特定のロールを持つユーザーのみアクセス可能

    認証・認可機能を確認する

    前項の認可機能を確認するため、

    • /index.html:ログイン後に遷移するHTML
    • /public/public.html:誰でもアクセス可能なHTML
    • /user/user.html:ログインしていればアクセス可能なHTML
    • /admin/admin.html:ADMINを持つユーザーのみアクセス可能なHTML

    の4ファイルをプロジェクト内に新規作成しました。(下図の赤枠部分)

    作成したファイルの階層図

    SecurityFilterChainオブジェクトで認証・認可機能を定義し、アプリケーションを起動してみます。

    起動すると、Spring Securityのデフォルトログインページが表示され、ログインするとindex.htmlが表示されます。

    index.html

    ログイン前後時の各HTMLファイルの認可可否の期待結果表を以下に作成しました。

    状態/HTML public.html user.html admin.html
    ログイン前
    ログイン後

    ※使用ユーザーは初期ユーザー(ロールなし)

    index.htmlのページ内にて、public.html / user.html / admin.htmlの各リンクを貼っているため、index.htmlから遷移して挙動を確認してみます。

    public.html / user.html

    public.html / user.html

    誰でもアクセス可能なpublic.htmlとログインしていればアクセス可能なuser.htmlは、index.htmlの表示時点で認可条件を満たすため、正常にHTMLの表示(Hello Public / Hello Userの表示)ができていることが確認できます。

    admin.html

    admin.html

    ADMINを持つユーザーのみアクセス可能なadmin.htmlは、index.htmlの表示時点で認可条件を満たしていないため、Spring Bootのエラー画面が表示されてしまいました。
    実際にHTTPステータスコードをみると、

    type=Forbidden, status=403

    と書いてあり、アクセス権限エラー = Spring Securityが正常に機能して認可によるエラーを出していることが確認できます。

    Step 3:ユーザーとロールを定義する

    Step 2で、ADMINを持つユーザーのみアクセス可能なadmin.htmlは、アクセス権限エラーが発生し閲覧できませんでした。
    admin.htmlはADMINを持つユーザーのみアクセス可能なため、対象ユーザーを新たに作成する必要があります。

    そこでStep 3では、 アプリケーションのメモリ上にユーザー情報を保持するための仕組みであるInMemoryUserDetailsManagerを使い、それぞれ異なる情報をもつユーザーを作成することで、Step 2のadmin.htmlを表示できるように設定します。

    InMemoryUserDetailsManagerを実装する

    SecurityConfigにUserDetailsServiceのBeanを定義します。
    ここで、「え?UserDetailsServiceって急に出てきたけどなに?」という疑問がありますが、 Spring Securityがユーザー情報(ユーザー名・パスワード・ロール)を取得するために、ユーザー情報を返却する仕組みとしてあるのがUserDetailsServiceです。これを@Beanで機能として登録しておくことで、Spring Securityが自動でそのユーザー情報を使って認証してくれる仕組みになります。

    SecurityConfig(Step 1~Step 3)
    @Configuration         // 設定ファイルであることを示すアノテーション
    @EnableWebSecurity     // Web 単位での制御を有効化(Step 2)
    public class SecurityConfig {
    
        public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
    
            http
                    // 認可(Authorization)(Step 2)
                    .authorizeHttpRequests(auth -> auth
                            // 誰でもアクセス可能
                            .requestMatchers("/", "/public/**").permitAll()
                            // ログインしていればアクセス可能
                            .requestMatchers("/user/**").authenticated()
                            // ADMIN を持つユーザーのみアクセス可能
                            .requestMatchers("/admin/**").hasRole("ADMIN")
                            .anyRequest().authenticated()
                    )
    
                    // 認証(Authentication)(Step 2)
                    .formLogin(form -> form
                            .permitAll()
                    )
                    .logout(logout -> logout
                            .logoutSuccessUrl("/")
                            .permitAll()
                    );
    
            return http.build();
        }
    
        // ユーザー情報の定義(Step 3)
        @Bean
        public UserDetailsService userDetailsService() {
    
            final UserDetails alice = User.withUsername("alice")
                    .password("{noop}password123")
                    .roles("ADMIN")
                    .build();
    
            final UserDetails bob = User.withUsername("bob")
                    .password("{noop}password123")
                    .roles("USER")
                    .build();
    
            return new InMemoryUserDetailsManager(alice, bob);
        }
    }
    

    ここでは、「alice」と「bob」の2ユーザーを作成しています。

    ユーザー名 パスワード ロール
    alice password123(ハッシュ化なし) ROLE_ADMIN (管理者ユーザー)
    bob password123(ハッシュ化なし)   ROLE_USER(一般ユーザー)    

    正確なロール名としてはROLE_ADMIN / ROLE_USERですが、Spring Securityでは、プレフィックスにROLE_を付与して内部的に処理しているため、我々開発者はSecurityConfig内で .roles("ADMIN") / .roles("USER")と、ROLE_を省略した形で書くことが可能になります 。

    admin.htmlが表示できるか確認する

    admin.htmlはADMINを持つユーザーのみアクセス可能なため、前項で作成したユーザーでログインした場合、以下の結果となります。

    • alice:ADMINロールのため、admin.htmlは表示可能

    • bob:USERロールのため、admin.htmlは表示不可

    以上の結果となるかどうか挙動を確認してみます。

    aliceでログインした場合のadmin.html

    admin.html

    aliceはADMINロールをもっているため、正常にHTMLの表示(Hello Adminの表示)ができていることが確認できます。

    bobでログインした場合のadmin.html

    admin.html

    bobはUSERロールをもっており、ADMINロールではないため、アクセス権限エラーが発生しています。

    Step 4:メソッドレベルの認可を実現する

    Step 3までは、URLパターンを使ったWebページ単位の認可機能を実装しました。これは画面や静的リソースを守るには有効ですが、ビジネスロジックの粒度で制御するにはやや不十分です。
    そこで、ControllerやServiceのメソッドに直接認可ルールを適用でき、URLだけでは防げない内部呼び出しなどを安全に実現するために、API(メソッド)単位での認可機能を実装してみます。

    API(メソッド)単位での認可機能を実装する

    API(メソッド)単位で認可機能の実装を行う場合、@EnableMethodSecurity + @PreAuthorizeにて可能です。

    • @EnableMethodSecurity
      ControllerやServiceなどのメソッドセキュリティを有効化するアノテーション。@PreAuthorize を使用するためにSecurityConfigで定義しておく。

    • @PreAuthorize
      メソッド呼び出し前に、SpEL(Spring Expression Language)で認可条件を評価するアノテーション。認可条件を満たしていない場合、 AccessDeniedExceptionを発生させてメソッドを実行しない。

    SecurityConfigでは、API用とWeb用でSecurityFilterChainを分けて作成し、それぞれに@Orderを付けて処理の優先順位を決めます。また、API側のリクエストかどうかはsecurityMatcherを使って判定します。

    SecurityConfig(Step 1~Step 4)
    @Configuration         // 設定ファイルであることを示すアノテーション
    @EnableWebSecurity     // Web 単位での制御を有効化(Step 2)
    @EnableMethodSecurity  // メソッド単位での制御を有効化(Step 4)
    public class SecurityConfig {
    
        // API 用認証・認可(Step 4)
        @Bean
        @Order(1)
        public SecurityFilterChain apiChain(final HttpSecurity http) throws Exception {
    
            http
                    .securityMatcher("/api/**")
                    .csrf(csrf -> csrf.disable())
                    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                    .authorizeHttpRequests(auth -> auth
                             // 誰でもアクセス可能
                            .requestMatchers("/api/hello").permitAll()
                             // それ以外は認証必須
                            .anyRequest().authenticated()
                    )
                    .httpBasic(Customizer.withDefaults())
                    .formLogin(form -> form.disable());
    
            return http.build();
        }
    
        // Web 用認証・認可(Step 2)
        @Bean
        @Order(2)
        public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
    
            http
                    // 認可(Authorization)(Step 2)
                    .authorizeHttpRequests(auth -> auth
                            // 誰でもアクセス可能
                            .requestMatchers("/", "/public/**").permitAll()
                            // ログインしていればアクセス可能
                            .requestMatchers("/user/**").authenticated()
                            // ADMIN を持つユーザーのみアクセス可能
                            .requestMatchers("/admin/**").hasRole("ADMIN")
                            .anyRequest().authenticated()
                    )
    
                    // 認証(Authentication)(Step 2)
                    .formLogin(form -> form
                            .permitAll()
                    )
                    .logout(logout -> logout
                            .logoutSuccessUrl("/")
                            .permitAll()
                    );
    
            return http.build();
        }
    
        // ユーザー情報の定義(Step 3)
        @Bean
        public UserDetailsService userDetailsService() {
    
            final UserDetails alice = User.withUsername("alice")
                    .password("{noop}password123")
                    .roles("ADMIN")
                    .build();
    
            final UserDetails bob = User.withUsername("bob")
                    .password("{noop}password123")
                    .roles("USER")
                    .build();
    
            return new InMemoryUserDetailsManager(alice, bob);
        }
    }
    

    com/example/demo(プロジェクト名)配下に、controller/DemoController(下図の赤枠部分)を作成し、メソッドに@PreAuthorizeを付与していきます。

    作成したファイルの階層図

    // Controllerであることを示すアノテーション
    @RestController
    // クラス全体の共通パスプレフィックス(この場合は/api/~~)を設定
    @RequestMapping("/api")
    public class DemoController {
    
        // GETメソッドに対応するルーティング(この場合は/api/hello)
        @GetMapping("/hello") 
        public String hello() {
            return "Hello Everyone";
        }
    
        @PreAuthorize("hasRole('USER')")
        // GETメソッドに対応するルーティング(この場合は/api/user)
        @GetMapping("/user")
        public String userOnly() {
            return "Hello USER";
        }
    
        @PreAuthorize("hasRole('ADMIN')")
        // GETメソッドに対応するルーティング(この場合は/api/admin)
        @GetMapping("/admin") 
        public String adminOnly() {
            return "Hello ADMIN";
        }
        
    }
    

    各メソッドの先頭についているアノテーション@PreAuthorize("hasRole('USER')") / @PreAuthorize("hasRole('ADMIN')")によってAPI(メソッド)単位での認可機能が有効化されています。

    • hasRole('USER') :(内部的に)ROLE_USER の有無を確認
    • hasRole('ADMIN') : (内部的に)ROLE_ADMIN の有無を確認

    hasRole()はメソッド実行時のユーザー情報を参照し、上記条件に合致する場合は文字列を返却し、合致しない場合はメソッドは実行されず 403 Forbidden を返却するといった処理です。

    Spring SecurityのhasRole()など、SecurityExpressionRootクラスのメソッドでは、内部的にROLE名にROLE_というプレフィックスを自動的に追加して評価するため、hasRole('USER') / hasRole('ADMIN')と指定しても、内部的にはROLE_USER / ROLE_ADMINと、正確なロールにて評価してくれます。

    各ユーザーのロールを使って、認可機能を確認する

    InMemoryUserDetailsManagerで作成したユーザーを使って、各メソッドの認可機能を確認してみます。

    前項で作成した各ユーザーと各メソッドについて、認可の期待結果表を作成しました。

    ユーザー/メソッド hello() userOnly() adminOnly()
    alice(管理者ユーザー)
    bob(一般ユーザー)

    以下のcurlコマンドを使って、認可設定どおりの挙動になっているかコンソールで確認します。

    curl.exe -i -u 「ユーザー名」:「パスワード」 http://localhost:8080/api/「メソッド名」
    正常系:alice + hello()
    PS C:\work\demo> curl.exe -i -u alice:password123 http://localhost:8080/api/hello
    HTTP/1.1 200 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 0
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: text/plain;charset=UTF-8
    Content-Length: 14
    Date: Thu, 08 Jan 2026 05:25:38 GMT
    
    Hello Everyone
    
    正常系:bob + userOnly()
    PS C:\work\demo> curl.exe -i -u bob:password123 http://localhost:8080/api/user
    HTTP/1.1 200 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 0
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: text/plain;charset=UTF-8
    Content-Length: 10
    Date: Thu, 08 Jan 2026 05:15:57 GMT
    
    Hello USER
    
    正常系:alice + adminOnly()
    PS C:\work\demo> curl.exe -i -u alice:password123 http://localhost:8080/api/admin
    HTTP/1.1 200 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 0
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: text/plain;charset=UTF-8
    Content-Length: 11
    Date: Thu, 08 Jan 2026 05:28:07 GMT
    
    Hello ADMIN
    

    認可機能が設定されていないhello()と各ユーザーに付与されたロールに合致したメソッド(alice:adminOnly() / bob:userOnly())は、正常に文字列の表示ができていることが確認できます。

    異常系:bob + adminOnly()
    PS C:\work\demo> curl.exe -i -u bob:password123 http://localhost:8080/api/admin  
    HTTP/1.1 403 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 0
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: application/json
    Transfer-Encoding: chunked
    Date: Thu, 08 Jan 2026 05:33:39 GMT
    
    {"timestamp":"2026-01-08T05:33:39.271Z","status":403,"error":"Forbidden", ...
    
    異常系:alice + userOnly()
    PS C:\work\demo> curl.exe -i -u alice:password123 http://localhost:8080/api/user 
    HTTP/1.1 403 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 0
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: application/json
    Transfer-Encoding: chunked
    Date: Thu, 08 Jan 2026 05:35:04 GMT
    
    {"timestamp":"2026-01-08T05:35:04.046Z","status":403,"error":"Forbidden", ...
    

    各ユーザーに付与されたロール外が認可条件となっているメソッドを呼ぶと、エラーが返却されました。
    エラーログをみると、

    "status":403,"error":"Forbidden"

    とあり、アクセス権限エラー = Spring Securityが正常に機能して認可によるエラーを返却していることが確認できます。

    Step 5:401 / 403のハンドリングを実装する

    Step 2・Step 3・Step 4では、エラーが発生した場合にSpring Bootのデフォルトのエラー画面が出てきました。
    しかし実際にWebアプリケーションを使う人からすると、「403とか英語が画面にたくさん出ているけど、何だろうこれ?」と何が原因でエラーが発生しているのか、分かってもらえません。

    そこで、Step 5を通して認証・認可機能で発生したエラーの適切なハンドリングを実装することで、クライアント側がエラーの種類を理解して適切な対応を取れるようになります。

    以下の認証・認可エラーに対し、適切なエラーハンドリングを実装します。

    • 401 Unauthorized
      未認証の状態で保護されたリソースを呼び出そうとしたことによる認証エラー
      例)ログインしていない状態で会員ページなどのログインが必要なページを開こうとしたときに発生するエラー
      AuthenticationEntryPointにて401を返却する

    • 403 Forbidden
      保護されたリソースを呼び出すロール・権限が付与されていないことによる認可エラー
      例)ログインはしているが、閲覧権限のないページにアクセスしようとしたときに発生するエラー
      AccessDeniedHandlerにて403を返却する

    AuthenticationEntryPoint(401)と AccessDeniedHandler(403)を実装する

    前項で説明したエラーが発生した場合に、オリジナルのエラーログ等を表示するために、エラーハンドリングを用意します。

    まず、SecurityFilterChainにて、認証(401)・認可(403)のエラーを検知した際にカスタムハンドラを呼び出すために、SecurityConfig内のHttpSecurity.exceptionHandlingにて各エラー用のカスタムハンドラ(AuthenticationEntryPoint / AccessDeniedHandler)を指定します。

    なお、今回はAPIでのエラーハンドリングを実装するため、API側にハンドラを適用します。

    SecurityConfig(Step 1~Step 5)
    @Configuration         // 設定ファイルであることを示すアノテーション
    @EnableWebSecurity     // Web 単位での制御を有効化(Step 2)
    @EnableMethodSecurity  // メソッド単位での制御を有効化(Step 4)
    public class SecurityConfig {
    
        // API 用認証・認可(Step 4)
        @Bean
        @Order(1)
        public SecurityFilterChain apiChain(
                final HttpSecurity http,
                final RestAuthenticationEntryPoint entryPoint,
                final RestAccessDeniedHandler accessDeniedHandler
        ) throws Exception {
    
            http
                    .securityMatcher("/api/**")
                    .csrf(csrf -> csrf.disable())
                    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                    .authorizeHttpRequests(auth -> auth
                             // 誰でもアクセス可能
                            .requestMatchers("/api/hello").permitAll()
                             // それ以外は認証必須
                            .anyRequest().authenticated()
                    )
                    // エラーハンドラの指定(Step 5)
                    .exceptionHandling(ex -> ex
                             // 401
                            .authenticationEntryPoint(entryPoint)
                             // 403
                            .accessDeniedHandler(accessDeniedHandler)
                    )
                    .httpBasic(Customizer.withDefaults())
                    .formLogin(form -> form.disable());
    
            return http.build();
        }
    
        // Web 用認証・認可(Step 2)
        @Bean
        @Order(2)
        public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
    
            http
                    // 認可(Authorization)(Step 2)
                    .authorizeHttpRequests(auth -> auth
                            // 誰でもアクセス可能
                            .requestMatchers("/", "/public/**").permitAll()
                            // ログインしていればアクセス可能
                            .requestMatchers("/user/**").authenticated()
                            // ADMIN を持つユーザーのみアクセス可能
                            .requestMatchers("/admin/**").hasRole("ADMIN")
                            .anyRequest().authenticated()
                    )
    
                    // 認証(Authentication)(Step 2)
                    .formLogin(form -> form
                            .permitAll()
                    )
                    .logout(logout -> logout
                            .logoutSuccessUrl("/")
                            .permitAll()
                    );
    
            return http.build();
        }
    
        // ユーザー情報の定義(Step 3)
        @Bean
        public UserDetailsService userDetailsService() {
    
            final UserDetails alice = User.withUsername("alice")
                    .password("{noop}password123")
                    .roles("ADMIN")
                    .build();
    
            final UserDetails bob = User.withUsername("bob")
                    .password("{noop}password123")
                    .roles("USER")
                    .build();
    
            return new InMemoryUserDetailsManager(alice, bob);
        }
    }
    

    これにより、エラーの出力先を束ねる役割をもつExceptionTranslationFilterが内部で自身のハンドラ(AuthenticationEntryPoint / AccessDeniedHandler)を呼ぶことができます。

    次に、各エラー用のカスタムハンドラ(AuthenticationEntryPoint / AccessDeniedHandler)を下図の赤枠部分のディレクトリに作成します。

    作成したファイルの階層図

    ハンドラ内では、ログへの記録と発生日時やステータスコード、オリジナルのエラーメッセージなどをコンソールに出力する処理としています。

    AuthenticationEntryPoint
    @Component
    public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
        private static final Logger log = LoggerFactory.getLogger(RestAuthenticationEntryPoint.class);
    
        @Override
        public void commence(final HttpServletRequest request, final HttpServletResponse response,
                             final AuthenticationException authException) throws IOException {
    
            final String traceId = UUID.randomUUID().toString();
            // ログへの記録
            log.warn("[401] path={} reason={} traceId={}",
                    request.getRequestURI(), authException.getClass().getSimpleName(), traceId);
    
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("""
                    {
                      "timestamp":"%s",
                      "status":401,
                      "error":"Unauthorized",
                      "code":"AUTHENTICATION_REQUIRED",
                      "message":"認証エラー:ログインが必要です",
                      "path":"%s",
                      "traceId":"%s"
                    }
                    """.formatted(Instant.now().toString(), request.getRequestURI(), traceId));
        }
    }
    
    AccessDeniedHandler
    @Component
    public class RestAccessDeniedHandler implements AccessDeniedHandler {
        private static final Logger log = LoggerFactory.getLogger(RestAccessDeniedHandler.class);
    
        @Override
        public void handle(final HttpServletRequest request, final HttpServletResponse response,
                           final AccessDeniedException ex) throws IOException {
    
            final String traceId = UUID.randomUUID().toString();
            // ログへの記録
            log.warn("[403] path={} reason={} traceId={}",
                    request.getRequestURI(), ex.getClass().getSimpleName(), traceId);
    
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("""
                    {
                      "timestamp":"%s",
                      "status":403,
                      "error":"Forbidden",
                      "code":"ACCESS_DENIED",
                      "message":"認可エラー:必要なロール・権限がありません",
                      "path":"%s",
                      "traceId":"%s"
                    }
                    """.formatted(Instant.now().toString(), request.getRequestURI(), traceId));
        }
    }
    

    401 / 403を発生させて、エラーハンドリングを確認する

    curlコマンドを使って、期待したエラーハンドリングの挙動になっているかコンソールで確認します。

    401(認証エラー)
    PS C:\work\demo> curl.exe -i http://localhost:8080/api/user              
    HTTP/1.1 401 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 0
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: application/json;charset=UTF-8
    Content-Length: 267
    Date: Thu, 08 Jan 2026 10:27:16 GMT
    
    {
      "timestamp":"2026-01-08T10:27:16.793328Z",
      "status":401,
      "error":"Unauthorized",
      "code":"AUTHENTICATION_REQUIRED",
      "message":"認証エラー:ログインが必要です",
      "path":"/api/user",
      "traceId":"ce6a0f03-be97-48fc-890c-3129f91bb81a"
    }
    
    403(認可エラー)
    PS C:\work\demo> curl.exe -i -u bob:password123  http://localhost:8080/api/admin
    HTTP/1.1 403 
    X-Content-Type-Options: nosniff
    X-XSS-Protection: 0
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Pragma: no-cache
    Expires: 0
    X-Frame-Options: DENY
    Content-Type: application/json;charset=UTF-8
    Content-Length: 258
    Date: Thu, 08 Jan 2026 10:54:23 GMT
    
    {
      "timestamp":"2026-01-08T10:54:23.432810600Z",
      "status":403,
      "error":"Forbidden",
      "code":"ACCESS_DENIED",
      "message":"認可エラー:必要なロール・権限がありません",
      "path":"/api/admin",
      "traceId":"450db7de-ad81-44fa-8dfe-2cac54378d67"
    }
    

    認証(401)・認可(403)エラー時に、各エラー用のカスタムハンドラが呼び出され、あらかじめ設定したオリジナルの出力になっていることが確認できます。

    おわりに

    いかがでしたでしょうか。

    冒頭で「ちょっと本気」と銘打っていた割には、まあまあボリューミーな内容となってしまいました。

    本記事では、Spring Securityを導入した直後の「とりあえず動いている状態」からスタートし、ステップを進めるごとに設定を細かく指定していきます。
    その過程で、「なぜこの挙動になるのか」「この設定は何を意味しているのか」を体験的に理解することで、Spring Securityを単に「動かせる」状態から、意図通りに「扱える」レベルへステップアップすることが目的です。
    このアプローチは、Spring Securityに限らず、多機能なフレームワークを使いこなす上で非常に重要な目線であると感じます。

    ....と偉そうなことを言っていますが、筆者もまだまだ勉強中の身です。
    今後は、JWTなどのトークン認証と組み合わせた機能など、より実際の形に近いセキュリティが実現できればと思います。

    ※繰り返しになりますが、今回は学習用デモとして、ベーシック認証など簡易的な実装で理解を進めました。実際のプロダクトではより適切な認証方式に置き換える必要があります。

    ここまで見ていただき、ありがとうございました!!!

    執筆者:藤本 梢吾(ふじもと しょうご)

    ■ ひよっこアプリケーションエンジニア🐣
    ■ アイドルと日本酒が好きです