Command Palette

Search for a command to run...

[Spring Boot Basics] Spring Security Overview: Authentication vs Authorization, the Filter Chain and SecurityFilterChain

Chapters 3 and 4 built a product catalogue API that anyone can call: DELETE /api/products/1 needs nothing but the URL. Article 15 fixed what a protected API must answer, 401 with a WWW-Authenticate header when it does not know who is calling and 403 when it knows and refuses, and article 20 noted that both come from Spring Security's filters, where an @ExceptionHandler cannot see them. This article opens Chapter 5 by adding Spring Security to the catalogue.

It starts with what the starter alone does to the running application, follows one request through the filter chain that produces those answers, and then replaces Boot's default with a SecurityFilterChain of its own, running into each trap on the way: a POST that fails with valid credentials, a rule order that silently opens an endpoint, a second chain that turns the API's 401 into a redirect to a login page. The examples use Spring Boot 4.1.1, which brings Spring Security 7.1.1, and Java 21, on an Initializr project with the web, validation and security dependencies. The app runs on port 8133 instead of the default 8080, so that is the port in the curl commands.

A request passing through a row of security filters and leaving as one of three answers: 200, 401 or 403

The security headers Spring Security adds to every response are shown in full once and left out of later curl -i outputs; log lines keep Boot's default pattern unless an excerpt says it was shortened.

The catalogue API used in this article

The API is Chapter 3's catalogue with an in-memory store, because nothing here needs a database. Generate the project:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,security" -o demo.zip
Bash
unzip demo.zip -d demo

Compared with the Chapter 3 projects, the generated build.gradle has one more starter and its test counterpart:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

The product classes live in com.example.demo.product:

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record Product(Long id, String sku, String name, BigDecimal price) {
}
src/main/java/com/example/demo/product/ProductResponse.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record ProductResponse(Long id, String sku, String name, BigDecimal price) {
 
    static ProductResponse from(Product product) {
        return new ProductResponse(product.id(), product.sku(), product.name(), product.price());
    }
}
src/main/java/com/example/demo/product/CreateProductRequest.java
package com.example.demo.product;
 
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
 
public record CreateProductRequest(
        @NotBlank String sku,
        @NotBlank String name,
        @NotNull @Positive BigDecimal price) {
}
src/main/java/com/example/demo/product/ProductNotFoundException.java
package com.example.demo.product;
 
public class ProductNotFoundException extends RuntimeException {
 
    public ProductNotFoundException(long id) {
        super("Product " + id + " not found");
    }
}
src/main/java/com/example/demo/product/ProductStore.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
 
@Component
public class ProductStore {
 
    private final Map<Long, Product> products = new ConcurrentSkipListMap<>();
    private final AtomicLong nextId = new AtomicLong(1);
 
    public ProductStore() {
        save(new CreateProductRequest("KB-001", "Mechanical keyboard", new BigDecimal("1290000")));
        save(new CreateProductRequest("MS-001", "Wireless mouse", new BigDecimal("490000")));
    }
 
    public List<Product> findAll() {
        return List.copyOf(products.values());
    }
 
    public Product findById(long id) {
        Product product = products.get(id);
        if (product == null) {
            throw new ProductNotFoundException(id);
        }
        return product;
    }
 
    public Product save(CreateProductRequest request) {
        long id = nextId.getAndIncrement();
        Product product = new Product(id, request.sku(), request.name(), request.price());
        products.put(id, product);
        return product;
    }
 
    public Product replace(long id, CreateProductRequest request) {
        findById(id);
        Product product = new Product(id, request.sku(), request.name(), request.price());
        products.put(id, product);
        return product;
    }
 
    public void delete(long id) {
        if (products.remove(id) == null) {
            throw new ProductNotFoundException(id);
        }
    }
}
src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductStore store;
 
    public ProductController(ProductStore store) {
        this.store = store;
    }
 
    @GetMapping
    public List<ProductResponse> findAll() {
        return store.findAll().stream().map(ProductResponse::from).toList();
    }
 
    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable long id) {
        return ProductResponse.from(store.findById(id));
    }
 
    @PostMapping
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
        Product product = store.save(request);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(product.id()).toUri();
        return ResponseEntity.created(location).body(ProductResponse.from(product));
    }
 
    @PutMapping("/{id}")
    public ProductResponse replace(@PathVariable long id, @Valid @RequestBody CreateProductRequest request) {
        return ProductResponse.from(store.replace(id, request));
    }
 
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable long id) {
        store.delete(id);
    }
}

The advice from article 20, in the common package since article 21, reduced to the one handler this article meets:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
package com.example.demo.common;
 
import com.example.demo.product.ProductNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
 
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
 
    @ExceptionHandler(ProductNotFoundException.class)
    public ProblemDetail handleNotFound(ProductNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Product not found");
        return problem;
    }
}

To see the API before security, the two security lines in build.gradle were commented out for one build:

Bash
./gradlew bootJar
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8133
Bash
curl -i http://localhost:8133/api/products
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 133
Date: Mon, 14 Sep 2026 04:16:21 GMT
 
[{"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":1290000},{"id":2,"sku":"MS-001","name":"Wireless mouse","price":490000}]

What spring-boot-starter-security changes

In an existing project the change is one starter and its test starter:

build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'

The Boot 4.1.1 BOM resolves it to spring-security-core, spring-security-web, spring-security-config and spring-security-crypto 7.1.1, plus Boot's spring-boot-security module. With the lines back in, rebuild and start the jar. No code changed, and the startup log has a new warning:

Text
2026-09-14T11:16:23.091+07:00  WARN 51467 --- [demo] [           main] .s.a.UserDetailsServiceAutoConfiguration :
 
Using generated security password: 8c715543-af93-4f23-be49-d6e09de09d3d
 
This generated password is for development use only. Your security configuration must be updated before running your application in production.

Boot created one user, named user, with a random password that changes on every start. Every endpoint, including the public-looking GET /api/products, now requires it.

A 401 for curl, a redirect to /login for a browser

Bash
curl -i http://localhost:8133/api/products
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=0CDD2FF2F5E062EB813E0B45ECC19E9B; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
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-Length: 0
Date: Mon, 14 Sep 2026 04:16:23 GMT

A 401 with a WWW-Authenticate: Basic challenge and an empty body, exactly the status article 15 asked for. The same request as a browser sends it:

Bash
curl -i -H 'Accept: text/html' http://localhost:8133/api/products
Text
HTTP/1.1 302
Set-Cookie: JSESSIONID=41C69D81FC08D53C4386A1DFA763AB3C; Path=/; HttpOnly
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
Location: http://localhost:8133/login
Content-Length: 0
Date: Mon, 14 Sep 2026 04:16:23 GMT

With logging.level.org.springframework.security=DEBUG, the redirect explains itself:

Text
2026-09-14T11:16:23.803+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.security.web.FilterChainProxy        : Securing GET /api/products
2026-09-14T11:16:23.803+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:16:23.804+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.w.s.HttpSessionRequestCache        : Saved request http://localhost:8133/api/products?continue to session
...
2026-09-14T11:16:23.804+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@16da1abc
2026-09-14T11:16:23.805+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.web.DefaultRedirectStrategy        : Redirecting to /login

Boot's default chain configures both form login and HTTP Basic, and a DelegatingAuthenticationEntryPoint picks one of them from the request's Accept header. For the request with Accept: application/json it logged No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@3bcf9488. The Set-Cookie is not a login: HttpSessionRequestCache created a session to remember the request, so it can be replayed after a successful login.

Accept sentResponseEntry pointSet-Cookie: JSESSIONID
*/* (curl's default)401, WWW-Authenticate: Basic realm="Realm", charset="UTF-8"BasicAuthenticationEntryPointyes, the request was saved
text/html302, Location: http://localhost:8133/loginLoginUrlAuthenticationEntryPointyes
application/json401, same challengeBasicAuthenticationEntryPointno, nothing was saved

The default login page

/login is served by Spring Security itself. Headless Chrome, driven over the DevTools protocol, opened /api/products, submitted the form once with a wrong password and once with the generated one. The top-level requests it recorded:

Text
1. open http://localhost:8133/api/products
  -> GET http://localhost:8133/api/products
  <- 302 Location: http://localhost:8133/login
  -> GET http://localhost:8133/login
  <- 200 text/html
2. submit a wrong password
  -> POST http://localhost:8133/login
  <- 302 Location: http://localhost:8133/login?error
  -> GET http://localhost:8133/login?error
  <- 200 text/html
3. submit the right password
  -> POST http://localhost:8133/login
  <- 302 Location: http://localhost:8133/api/products?continue
  -> GET http://localhost:8133/api/products?continue
  <- 200 application/json

The page is titled "Please sign in" and holds a form posting to /login with a username field, a password field, a hidden _csrf input carrying a 96-character token, and a "Sign in" button, styled by /default-ui.css. After the wrong password it showed "Invalid credentials". After the right one, the saved request came back with ?continue and the browser got the product JSON, with a JSESSIONID cookie (HttpOnly) now holding the login for its next requests.

HTTP Basic with the generated password

An API client sends the credentials with every request instead:

Bash
curl -i -u user:8c715543-af93-4f23-be49-d6e09de09d3d http://localhost:8133/api/products
Text
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: application/json
Content-Length: 133
Date: Mon, 14 Sep 2026 04:16:24 GMT
 
[{"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":1290000},{"id":2,"sku":"MS-001","name":"Wireless mouse","price":490000}]

The body is the one from before the starter; the six headers from X-Content-Type-Options to X-Frame-Options are new on every response and are left out from here on. The successful request created no session. With -u user:wrong the answer was the same 401 challenge as with no credentials at all.

spring.security.user.name, password and roles

A password that changes on every start is no use beyond the first minute. Boot's user can be fixed in configuration:

src/main/resources/application.properties
spring.security.user.name=alice
spring.security.user.password=alice-secret
spring.security.user.roles=USER

With these properties, the startup log printed no generated password, alice:alice-secret authenticated, and the old user was gone: curl -i -u user:alice-secret http://localhost:8133/api/me answered 401 with the same challenge. /api/me is a small endpoint added in the authentication section below. The rest of the article uses alice until a real user store replaces the properties.

How a request passes through the Spring Security filter chain

Spring Security is not part of Spring MVC. It is a servlet filter that Tomcat runs before DispatcherServlet, and inside it an ordered list of filters of its own.

DelegatingFilterProxy and FilterChainProxy

With logging.level.org.springframework.boot.web.servlet=DEBUG, Boot lists the filters it registers with Tomcat:

Text
2026-09-14T11:16:29.591+07:00 DEBUG 51539 --- [demo] [           main] o.s.b.w.s.ServletContextInitializerBeans : Mapping filters: springSecurityFilterChain urls=[/*] order=-100, characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105
2026-09-14T11:16:29.591+07:00 DEBUG 51539 --- [demo] [           main] o.s.b.w.s.ServletContextInitializerBeans : Mapping servlets: dispatcherServlet urls=[/]
2026-09-14T11:16:29.607+07:00 DEBUG 51539 --- [demo] [           main] .DelegatingFilterProxyRegistrationBean$1 : Filter 'springSecurityFilterChain' configured for use

springSecurityFilterChain is mapped to every URL at order -100, the default of spring.security.filter.order, so it runs after the character encoding, form content and request context filters. What Tomcat holds is a DelegatingFilterProxy: Tomcat only knows servlet filters, and the proxy hands each request to the Spring bean of that name. A temporary new Exception("call path").printStackTrace(System.out) in ProductController.findAll() shows the whole route for an authenticated GET /api/products. Read it from the bottom up:

Text
java.lang.Exception: call path
	at com.example.demo.product.ProductController.findAll(ProductController.java:32)
	...
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963)
	...
	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:59)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108)
	at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$2(FilterChainProxy.java:235)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:376)
	at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:101)
	...
	at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126)
	...
	at org.springframework.security.web.authentication.www.BasicAuthenticationFilter.doFilterInternal(BasicAuthenticationFilter.java:247)
	...
	at org.springframework.security.web.csrf.CsrfFilter.doFilterInternal(CsrfFilter.java:118)
	...
	at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82)
	...
	at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385)
	at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237)
	at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195)
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
	at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52)
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
	at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74)
	at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317)
	at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355)
	at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
	...
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:199)
	...
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:165)
  • Tomcat's ApplicationFilterChain calls CharacterEncodingFilter, RequestContextFilter, then DelegatingFilterProxy.
  • DelegatingFilterProxy calls the springSecurityFilterChain bean. In Spring Security 7.1.1 that bean is WebSecurityConfiguration$CompositeFilterChainProxy, which runs ServletRequestPathFilter and then FilterChainProxy.
  • FilterChainProxy picks a SecurityFilterChain and runs its filters through a VirtualFilterChain, each one nested inside the previous: DisableEncodeUrlFilter at the bottom, AuthorizationFilter at the top.
  • After the last filter, FilterChainProxy.lambda$doFilterInternal$2 hands the request back to Tomcat's chain, which reaches HttpServlet.service, DispatcherServlet and the controller.

Every security decision is made before DispatcherServlet is called. That is why article 20's advice never sees them, which a later section shows in practice.

The filters Spring Security prints at startup

FilterChainProxy can hold several SecurityFilterChains. With only the starter, it holds Boot's default one, and the DEBUG level set earlier prints it once at startup:

src/main/resources/application.properties
logging.level.org.springframework.security=DEBUG
Text
2026-09-14T11:16:23.137+07:00 DEBUG 51467 --- [demo] [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter

Sixteen filters, applied to "any request". Their order is fixed by Spring Security, not by the order in which a configuration mentions them.

One request through the Spring Security filter chain: Tomcat's filter chain calls DelegatingFilterProxy, registered as springSecurityFilterChain at order -100, which calls FilterChainProxy; FilterChainProxy runs the defaultSecurityFilterChain for any request, whose 16 filters are numbered in startup order with SecurityContextHolderFilter, CsrfFilter, UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter and AuthorizationFilter highlighted; only after AuthorizationFilter does the request reach DispatcherServlet and ProductController.findAll()

One request, filter by filter

At TRACE, FilterChainProxy logs each filter it invokes. The authenticated GET /api/products from the stack trace, with the timestamp, PID and thread cut from each line and some lines skipped (...):

Text
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'defaultSecurityFilterChain' in [class path resource [org/springframework/boot/security/autoconfigure/web/servlet/ServletWebSecurityAutoConfiguration$SecurityFilterChainConfiguration.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /api/products
TRACE o.s.security.web.FilterChainProxy        : Invoking DisableEncodeUrlFilter (1/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking WebAsyncManagerIntegrationFilter (2/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderFilter (3/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking HeaderWriterFilter (4/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking CsrfFilter (5/16)
TRACE s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken]
TRACE o.s.security.web.csrf.CsrfFilter         : Did not protect against CSRF since request did not match IsNotHttpMethod [TRACE, HEAD, GET, OPTIONS]
TRACE o.s.security.web.FilterChainProxy        : Invoking LogoutFilter (6/16)
TRACE o.s.s.w.a.logout.LogoutFilter            : Did not match request to PathPattern [POST /logout]
TRACE o.s.security.web.FilterChainProxy        : Invoking UsernamePasswordAuthenticationFilter (7/16)
TRACE w.a.UsernamePasswordAuthenticationFilter : Did not match request to PathPattern [POST /login]
TRACE o.s.security.web.FilterChainProxy        : Invoking DefaultResourcesFilter (8/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking DefaultLoginPageGeneratingFilter (9/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking DefaultLogoutPageGeneratingFilter (10/16)
TRACE .w.a.u.DefaultLogoutPageGeneratingFilter : Did not render default logout page since request did not match [PathPattern [GET /logout]]
TRACE o.s.security.web.FilterChainProxy        : Invoking BasicAuthenticationFilter (11/16)
TRACE o.s.s.w.a.www.BasicAuthenticationFilter  : Found username 'user' in Basic Authorization header
TRACE w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
TRACE .s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
...
TRACE o.s.s.authentication.ProviderManager     : Authenticating request with DaoAuthenticationProvider (1/1)
DEBUG o.s.s.a.dao.DaoAuthenticationProvider    : Authenticated user
DEBUG o.s.s.w.a.www.BasicAuthenticationFilter  : Set SecurityContextHolder to UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=user, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T04:16:30.063207Z]]]
TRACE o.s.security.web.FilterChainProxy        : Invoking RequestCacheAwareFilter (12/16)
...
TRACE o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderAwareRequestFilter (13/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking AnonymousAuthenticationFilter (14/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking ExceptionTranslationFilter (15/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking AuthorizationFilter (16/16)
TRACE estMatcherDelegatingAuthorizationManager : Authorizing GET /api/products
TRACE estMatcherDelegatingAuthorizationManager : Checking authorization on GET /api/products using org.springframework.security.authorization.AuthenticatedAuthorizationManager@decffa3
...
DEBUG o.s.security.web.FilterChainProxy        : Secured GET /api/products

The filters worth knowing

Most filters in that list did nothing for this request. Six of them decide every outcome in this article:

  • SecurityContextHolderFilter (3) makes the SecurityContext available through SecurityContextHolder for the rest of the request. Loading is deferred: the repository was consulted only when BasicAuthenticationFilter first needed the context (No HttpSession currently exists).
  • CsrfFilter (5) leaves GET, HEAD, TRACE and OPTIONS alone and requires a CSRF token on every other method. For this GET it only put a token into request attributes.
  • UsernamePasswordAuthenticationFilter (7) and BasicAuthenticationFilter (11) are the two ways to log in. The first acts only on POST /login from the form, the second on an Authorization: Basic header. On success they put an Authentication into SecurityContextHolder.
  • AnonymousAuthenticationFilter (14) sets an AnonymousAuthenticationToken for anonymousUser with ROLE_ANONYMOUS when nothing authenticated the request, so the filters after it never meet a missing Authentication.
  • ExceptionTranslationFilter (15) does nothing on the way in. It wraps the filter after it and turns a denial into a 401 or a 403, as the authentication section shows.
  • AuthorizationFilter (16) checks the authorization rules for the request. Boot's default rule is "authenticated"; only if it passes does the request continue to DispatcherServlet.

The rest are supporting cast: HeaderWriterFilter writes the six security headers, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter and DefaultResourcesFilter serve the login page, logout page and /default-ui.css, RequestCacheAwareFilter replays the request saved before a login (?continue), SecurityContextHolderAwareRequestFilter makes request.getUserPrincipal() and isUserInRole() answer from Spring Security, and DisableEncodeUrlFilter keeps session ids out of URLs.

Note the Granted Authorities=[] on the principal and FACTOR_PASSWORD on the token: Boot's generated user has no roles, and Spring Security 7 records which kind of credential authenticated the request. The authentication section comes back to it.

Writing your own SecurityFilterChain

Boot's chain protects every URL the same way and offers a login page no API client will use. The catalogue needs different rules: anyone may read products, only an admin may delete one, every other request needs credentials, and the API uses HTTP Basic without a login form. In Spring Security 7.1.1 that is a SecurityFilterChain bean:

src/main/java/com/example/demo/common/SecurityConfig.java
package com.example.demo.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .formLogin(form -> form.disable());
        return http.build();
    }
}
  • HttpSecurity is a builder for one chain, injected by Spring Security. Each method takes a lambda that configures one feature, and http.build() produces the chain.
  • authorizeHttpRequests lists rules: requestMatchers(HttpMethod.GET, "/api/products/**") matches the list and every product, permitAll() lets anyone through, hasRole("ADMIN") requires the authority ROLE_ADMIN, and anyRequest().authenticated() covers everything the rules above did not.
  • httpBasic(Customizer.withDefaults()) keeps BasicAuthenticationFilter; formLogin(form -> form.disable()) says there is no login form.

This lambda style is the only one Spring Security 7.1.1 offers. WebSecurityConfigurerAdapter was removed in Spring Security 6.0, and the 7.1.1 jars have no antMatchers, mvcMatchers, authorizeRequests() or and(). HttpSecurity.build() is declared as public final O build() without throws Exception, so the bean method needs no throws clause either.

The chain after a restart, from the DEBUG line:

Text
2026-09-14T11:21:13.878+07:00 DEBUG 52518 --- [demo] [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter

Twelve filters: the form login filter and the three filters behind the default pages are gone. The rules at work:

Bash
curl -i http://localhost:8133/api/products/99
Text
HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
 
{"detail":"Product 99 not found","instance":"/api/products/99","status":404,"title":"Product not found"}

A public request without credentials reached the controller, and the advice still answered with its ProblemDetail. GET /api/products answered 200 the same way. A protected endpoint without credentials:

Bash
curl -i http://localhost:8133/api/me
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=7B9D2ED025F81D54173650EEFFD81FC0; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0

With -H 'Accept: text/html' the answer was the same 401 instead of a redirect, and GET /login also answered 401: there is no login page to redirect to.

Is @EnableWebSecurity needed in Spring Boot 4?

No. SecurityConfig above carries only @Configuration, and its chain is the one that ran. Boot supplies the annotation: ServletWebSecurityAutoConfiguration.EnableWebSecurityConfiguration in spring-boot-security 4.1.1 is annotated with @EnableWebSecurity and applies while no springSecurityFilterChain bean exists. The conditions report of a run with --debug:

Text
   ServletWebSecurityAutoConfiguration.EnableWebSecurityConfiguration matched:
      - @ConditionalOnClass found required class 'org.springframework.security.config.annotation.web.configuration.EnableWebSecurity' (OnClassCondition)
      - @ConditionalOnMissingBean (names: springSecurityFilterChain; SearchStrategy: all) did not find any beans (OnBeanCondition)

Spring Boot's default chain backs off

The same report explains why the sixteen-filter chain is gone:

Text
   ServletWebSecurityAutoConfiguration.SecurityFilterChainConfiguration:
      Did not match:
         - AllNestedConditions 1 matched 1 did not; NestedCondition on DefaultWebSecurityCondition.Beans @ConditionalOnMissingBean (types: org.springframework.security.web.SecurityFilterChain; SearchStrategy: all) found beans of type 'org.springframework.security.web.SecurityFilterChain' securityFilterChain; NestedCondition on DefaultWebSecurityCondition.Classes @ConditionalOnClass found required classes 'org.springframework.security.web.SecurityFilterChain', 'org.springframework.security.config.annotation.web.builders.HttpSecurity' (DefaultWebSecurityCondition)

Boot defines defaultSecurityFilterChain only when there is no bean of type SecurityFilterChain. In the starter-only run the same condition read did not find any beans and the configuration matched. As soon as one chain of yours exists, Boot's is not created: none of its rules survive, and nothing is merged.

Why POST fails with valid credentials: CSRF

With the chain in place, alice creates a product:

Bash
curl -i -X POST -u alice:alice-secret -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8133/api/products
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=0645DFDFC5BB35281D24E97344AC1D7B; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:21:17 GMT

A 401 for credentials that worked a second earlier on GET /api/me. DELETE /api/products/2 with the same credentials got the same 401.

The 403 that arrives as a 401

The DEBUG log of that POST:

Text
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.FilterChainProxy        : Securing POST /api/products
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.csrf.CsrfFilter         : Invalid CSRF token found for http://localhost:8133/api/products
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.access.AccessDeniedHandlerImpl   : Responding with 403 status code
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.FilterChainProxy        : Securing GET /error
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.s.HttpSessionRequestCache        : Saved request http://localhost:8133/error?continue to session
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] s.w.a.DelegatingAuthenticationEntryPoint : No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@28a59257
  1. CsrfFilter rejected the request with 403. It is filter 5; BasicAuthenticationFilter is filter 7 in this chain, so alice's credentials were never checked. AccessDeniedHandlerImpl answered by calling sendError(403).
  2. Tomcat forwarded the error to /error, and that ERROR dispatch went through the security chain again.
  3. /error was anonymous. BasicAuthenticationFilter extends OncePerRequestFilter, whose shouldNotFilterErrorDispatch() returns true in spring-web 7.0.9 and is not overridden, so it does not run on an ERROR dispatch either.
  4. anyRequest().authenticated() denied /error, and the Basic entry point wrote a 401 over the 403.

Boot's default chain does exactly the same: the same POST with the generated password, sent to the starter-only application, also came back as a 401. Letting the ERROR dispatch through shows the real status:

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/error").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
Text
HTTP/1.1 403
Set-Cookie: JSESSIONID=DDC0ED96C8FDFF9FDBC9C9FF5C0F3CB5; Path=/; HttpOnly
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:20 GMT
 
{"timestamp":"2026-09-14T04:21:20.178Z","status":403,"error":"Forbidden","path":"/api/products"}

The log now ends with Secured GET /error, and Boot's error controller wrote its JSON body. This rule was a diagnostic and is removed again; the final configuration writes its 401 and 403 responses without an ERROR dispatch.

CSRF, cross-site request forgery, is an attack in which another website makes a logged-in user's browser send a request that changes something, relying on the browser to attach that user's session cookie by itself. Spring Security's defence is a token the other site cannot read, required on every request except GET, HEAD, TRACE and OPTIONS, and curl sent none.

Disabling CSRF for a stateless API

src/main/java/com/example/demo/common/SecurityConfig.java
                .httpBasic(Customizer.withDefaults())
                .formLogin(form -> form.disable()); 
                .formLogin(form -> form.disable()) 
                .csrf(csrf -> csrf.disable()); 

⚠️ Turning CSRF off is right for this API only because nothing in it authenticates a request automatically: every call carries its credentials in the Authorization header, there is no login form, and the next section removes the session cookie as well. A chain that keeps a login in a cookie, such as the form login chain later in this article, must keep CSRF protection on. Article 36 covers when each case applies and how to use the token.

The same POST after a restart:

Text
HTTP/1.1 201
Location: http://localhost:8133/api/products/3
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:26 GMT
 
{"id":3,"sku":"HB-001","name":"USB-C hub","price":350000}

Authentication vs authorization: 401 and 403

Authentication answers who is calling; authorization answers is this caller allowed to do this. Spring Security settles the first before it asks the second, and each failure has its own status. Against the chain above, a POST without credentials:

Bash
curl -i -X POST -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8133/api/products
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=6F6348023468E120EE02EF439F436B96; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:21:26 GMT

The same POST with -u alice:wrong got the same 401, and the log shows where it stopped:

Text
2026-09-14T11:21:27.131+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.a.dao.DaoAuthenticationProvider    : Failed to authenticate since password does not match stored value
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.authentication.ProviderManager     : Authentication failed with provider DaoAuthenticationProvider since Bad credentials
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.authentication.ProviderManager     : Denying authentication since all attempted providers failed
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.w.a.www.BasicAuthenticationFilter  : Failed to process authentication request
 
org.springframework.security.authentication.BadCredentialsException: Bad credentials

Valid credentials, but a rule alice does not satisfy: she has ROLE_USER and DELETE needs ROLE_ADMIN.

Bash
curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2
Text
HTTP/1.1 403
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:27 GMT
 
{"timestamp":"2026-09-14T04:21:27.646Z","status":403,"error":"Forbidden","path":"/api/products/2"}
Text
2026-09-14T11:21:27.571+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy        : Securing DELETE /api/products/2
2026-09-14T11:21:27.643+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.s.a.dao.DaoAuthenticationProvider    : Authenticated user
...
2026-09-14T11:21:27.644+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.s.w.access.AccessDeniedHandlerImpl   : Responding with 403 status code
2026-09-14T11:21:27.645+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy        : Securing GET /error
2026-09-14T11:21:27.645+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy        : Secured GET /error

The 403 has a body and the 401 has none, although both went through sendError and an ERROR dispatch. For alice the dispatch to /error was authenticated (Secured GET /error), so Boot's error controller wrote its JSON; for the anonymous request it was denied again and the body stayed empty.

Side by side: POST /api/products with no Authorization header passes AnonymousAuthenticationFilter as anonymousUser, fails anyRequest().authenticated() in AuthorizationFilter, ExceptionTranslationFilter sends the anonymous caller to the AuthenticationEntryPoint and the answer is HTTP/1.1 401 with WWW-Authenticate: Basic realm="Realm"; DELETE /api/products/2 as alice with ROLE_USER is authenticated by BasicAuthenticationFilter, fails hasRole("ADMIN"), ExceptionTranslationFilter sends the authenticated caller to the AccessDeniedHandler and the answer is HTTP/1.1 403

ExceptionTranslationFilter, AuthenticationEntryPoint and AccessDeniedHandler

AuthorizationFilter does not write responses. When a rule fails it throws AuthorizationDeniedException, and ExceptionTranslationFilter, which wraps it, decides what the denial means from the caller in SecurityContextHolder. With logging.level.org.springframework.security.web.access.ExceptionTranslationFilter=TRACE it names the branch. For the anonymous GET /api/products in the starter-only run:

Text
2026-09-14T11:16:30.552+07:00 TRACE 51539 --- [demo] [nio-8133-exec-2] o.s.s.w.a.ExceptionTranslationFilter     : Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] to authentication entry point since access is denied
 
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied

For alice's DELETE in the final configuration at the end of this article, with the prefix cut and the token details shortened to […]:

Text
TRACE o.s.s.w.a.ExceptionTranslationFilter     : Sending UsernamePasswordAuthenticationToken […] to access denied handler since access is denied
 
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
  • An anonymous caller goes to the AuthenticationEntryPoint. Its job is to start authentication: BasicAuthenticationEntryPoint answers 401 with the challenge, LoginUrlAuthenticationEntryPoint redirects to the login page.
  • An authenticated caller goes to the AccessDeniedHandler. Logging in again would not change the answer, so AccessDeniedHandlerImpl answers 403.
  • A wrong password never reaches AuthorizationFilter. BasicAuthenticationFilter catches the BadCredentialsException itself and calls its own entry point, which is why Failed to process authentication request is the last Spring Security line before the 401.

What SecurityContextHolder holds after a login

What a successful login leaves behind is easiest to see from a controller. GET /api/me receives the Authentication and the principal as parameters and compares them with SecurityContextHolder:

src/main/java/com/example/demo/common/CurrentUserController.java
package com.example.demo.common;
 
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class CurrentUserController {
 
    private static final Logger log = LoggerFactory.getLogger(CurrentUserController.class);
 
    public record CurrentUserResponse(String name, List<String> authorities, String authenticationType,
                                      String principalType, boolean credentialsPresent,
                                      boolean sameAsSecurityContextHolder) {
    }
 
    @GetMapping("/api/me")
    public CurrentUserResponse me(Authentication authentication, @AuthenticationPrincipal UserDetails principal) {
        Authentication fromHolder = SecurityContextHolder.getContext().getAuthentication();
        log.info("{}", fromHolder);
        return new CurrentUserResponse(
                authentication.getName(),
                authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(),
                authentication.getClass().getName(),
                principal.getClass().getName(),
                authentication.getCredentials() != null,
                fromHolder == authentication);
    }
}
Bash
curl -i -u alice:alice-secret http://localhost:8133/api/me
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 296
Date: Mon, 14 Sep 2026 04:21:29 GMT
 
{"name":"alice","authorities":["ROLE_USER","FACTOR_PASSWORD"],"authenticationType":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principalType":"org.springframework.security.core.userdetails.User","credentialsPresent":false,"sameAsSecurityContextHolder":true}
Text
2026-09-14T11:21:29.099+07:00  INFO 52717 --- [demo] [nio-8133-exec-2] c.e.demo.common.CurrentUserController    : UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T04:21:29.097929Z]]]
  • SecurityContextHolder holds a SecurityContext whose Authentication is a UsernamePasswordAuthenticationToken. The controller parameter is the same object (sameAsSecurityContextHolder: true).
  • The principal is a UserDetails, here Spring Security's User, with the roles it was created with: ROLE_USER, from spring.security.user.roles=USER.
  • The credentials are gone. getCredentials() returned null after authentication, so the password does not travel further into the application.
  • The token's authorities add FACTOR_PASSWORD. Spring Security 7 adds a FactorGrantedAuthority naming the kind of credential that authenticated the request, with the time it was issued. spring-security-config 7.1.1 also contains @EnableMultiFactorAuthentication; multi-factor authentication is a topic for the Advanced course.
  • Details records the remote address and the session id, null here because HTTP Basic created no session.

A stateless API with SessionCreationPolicy.STATELESS

Several 401s above set a JSESSIONID cookie on a client that never logs in through a session. One setting stops it:

src/main/java/com/example/demo/common/SecurityConfig.java
import org.springframework.security.config.http.SessionCreationPolicy; 
 
// ...
                .formLogin(form -> form.disable())
                .csrf(csrf -> csrf.disable()); 
                .csrf(csrf -> csrf.disable()) 
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); 

The POST without credentials, before and after:

Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=6F6348023468E120EE02EF439F436B96; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0

The log line Saved request http://localhost:8133/api/products?continue to session is gone with the cookie: a stateless chain does not save requests for a login page it does not have. SessionManagementFilter joined the startup filter list. What did not change: a successful Basic request created no session in either configuration. curl -c wrote an empty cookie jar after GET /api/me as alice in both, and without STATELESS a follow-up request with that jar and no credentials got a plain 401. HTTP Basic sends credentials on every request, so there is no session to keep, and STATELESS makes sure none appears by accident.

requestMatchers order: the first match wins

AuthorizationFilter checks the rules from top to bottom and uses the first one whose matcher fits the request. It does not look for the most specific rule. The next three runs change only the rules of the stateless chain.

A rule order that closes an endpoint

A rule for the whole API added above the public one:

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/**").authenticated() 
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
Bash
curl -i http://localhost:8133/api/products
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:35:52 GMT

GET /api/products matched /api/** first, so permitAll() below it never applied, and the product list now requires credentials (-u alice:alice-secret got 200). The application started without a single WARN line.

A rule order that opens an endpoint

The opposite mistake is worse. The public rule written without its method, above the admin rule:

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() 
                        .requestMatchers("/api/products/**").permitAll() 
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
Bash
curl -i -X DELETE http://localhost:8133/api/products/2
Text
HTTP/1.1 204
Date: Mon, 14 Sep 2026 04:21:38 GMT
Text
2026-09-14T11:21:38.090+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.security.web.FilterChainProxy        : Securing DELETE /api/products/2
2026-09-14T11:21:38.092+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:21:38.093+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.security.web.FilterChainProxy        : Secured DELETE /api/products/2

An anonymous caller deleted product 2; the next GET /api/products listed only the keyboard. hasRole("ADMIN") is still in the configuration and never runs. Put narrow rules above broad ones, keep the HTTP method in rules that are meant for one method, and end with anyRequest(). That last position is the only one the configurer enforces: AbstractRequestMatcherRegistry in the 7.1.1 jar carries the message Can't configure requestMatchers after anyRequest.

permitAll() vs anonymous()

The public rule with anonymous() instead of permitAll():

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").anonymous()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())

Without credentials GET /api/products answered 200. With alice's:

Bash
curl -i -u alice:alice-secret http://localhost:8133/api/products
Text
HTTP/1.1 403
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:41 GMT
 
{"timestamp":"2026-09-14T04:21:41.338Z","status":403,"error":"Forbidden","path":"/api/products"}

permitAll() allows every caller. anonymous() allows only a request that AnonymousAuthenticationFilter marked as anonymousUser, so a logged-in user is refused; it fits a registration page, not a public catalogue. Neither rule ignores credentials that are sent: with permitAll(), curl -i -u alice:wrong http://localhost:8133/api/products answered 401, because BasicAuthenticationFilter rejects a bad Authorization header before any rule is checked.

Two SecurityFilterChains with securityMatcher and @Order

Most applications also serve something other than the API: an admin page, a server-rendered site like article 24's. Those want a login form, a session and CSRF protection, which is everything the API chain turned off. Instead of one chain that compromises, define two:

src/main/java/com/example/demo/common/SecurityConfig.java
package com.example.demo.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    @Order(1)
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) {
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
 
    @Bean
    @Order(2)
    SecurityFilterChain webSecurityFilterChain(HttpSecurity http) {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults());
        return http.build();
    }
}
src/main/java/com/example/demo/common/HomeController.java
package com.example.demo.common;
 
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class HomeController {
 
    @GetMapping("/")
    public String home() {
        return "Catalogue home";
    }
}
  • securityMatcher("/api/**") decides which requests a chain handles at all. requestMatchers inside authorizeHttpRequests only decide rules within the chain.
  • @Order decides the order in which FilterChainProxy asks the chains. The web chain has no securityMatcher, so it matches every request.

The startup log prints both chains:

Text
2026-09-14T11:29:06.004+07:00 DEBUG 56717 --- [demo] [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure Or [PathPattern [/api/**]] with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, LogoutFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilter
2026-09-14T11:29:06.018+07:00 DEBUG 56717 --- [demo] [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter

The API chain has no CsrfFilter and no UsernamePasswordAuthenticationFilter, although it never calls formLogin(form -> form.disable()): a chain built from HttpSecurity has form login only if it calls formLogin(). The line in the single-chain version documented a decision rather than changing the filters. The web chain has the login filters and CsrfFilter, and no BasicAuthenticationFilter.

Which chain handled the request

With logging.level.org.springframework.security.web.FilterChainProxy=TRACE, each request logs the chains it was matched against. The prefixes are cut and the filter lists replaced by […]:

Text
### GET /api/products -> 200
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /api/products
DEBUG o.s.security.web.FilterChainProxy        : Secured GET /api/products
 
### GET / -> 302 Location: http://localhost:8133/login
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters […] (2/2)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /
DEBUG o.s.s.web.DefaultRedirectStrategy        : Redirecting to /login

/api/products matched the first chain and the second was never asked. / did not match /api/**, so FilterChainProxy moved to the second chain, which matches anything, and its form login entry point redirected to /login, even with -u alice:alice-secret, because this chain does not read Basic credentials.

How FilterChainProxy picks a chain: GET /api/products is tried against apiSecurityFilterChain with @Order(1) and matcher Or [PathPattern [/api/**]], matches, and gets HTTP/1.1 200 without webSecurityFilterChain being asked; GET / does not match the first chain, matches webSecurityFilterChain with @Order(2) for any request, and gets HTTP/1.1 302 to http://localhost:8133/login; with the @Order values swapped, webSecurityFilterChain matches every request first, apiSecurityFilterChain would never be invoked, and the application fails at startup with UnreachableFilterChainException

A 401 that turns into a redirect to /login

The API chain has not changed, yet an unauthenticated API call no longer gets its 401:

Bash
curl -i http://localhost:8133/api/me
Text
HTTP/1.1 302
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Set-Cookie: JSESSIONID=AE729E73CC623615A92322DA476669D5; Path=/; HttpOnly
Location: http://localhost:8133/login;jsessionid=AE729E73CC623615A92322DA476669D5
Content-Length: 0
Date: Mon, 14 Sep 2026 04:29:06 GMT
Text
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /api/me
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters […] (2/2)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /error
DEBUG o.s.s.web.DefaultRedirectStrategy        : Redirecting to /login;jsessionid=AE729E73CC623615A92322DA476669D5

The API chain did answer 401: its WWW-Authenticate header is still in the response. But the Basic entry point uses sendError, the ERROR dispatch went to /error, and /error is not under /api/**. The web chain took it, found an anonymous caller, and its form login entry point replaced the 401 with a redirect. The session id in Location comes from the same dispatch: DisableEncodeUrlFilter, which keeps session ids out of URLs, is also a OncePerRequestFilter that does not override shouldNotFilterErrorDispatch(), so it did not run for /error either. The same happened with -H 'Accept: text/html' and with a wrong password. An authenticated denial still worked: alice's DELETE /api/products/2 answered 403 with Boot's JSON body, because the ERROR dispatch for her was authenticated. The next section removes the ERROR dispatch from the API's answers altogether.

Reversing the @Order values

With @Order(1) on the web chain and @Order(2) on the API chain, the application did not start:

Text
2026-09-14T11:29:10.892+07:00 ERROR 58644 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
...
Caused by: org.springframework.security.web.UnreachableFilterChainException: A filter chain that matches any request [DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization]] has already been configured, which means that this filter chain [DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Logout, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]] will never get invoked. Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured for 'any request' and that the 'any request' filter chain is published last.

Unlike rules inside one chain, chain order has a safety net for this case: a chain that matches any request ahead of another chain is rejected at startup. The message is about "any request"; for chains with narrower matchers that overlap, such as /api/** and /api/products/**, give the narrower one the lower @Order value.

Returning 401 and 403 as ProblemDetail

The API now answers a 401 with an empty body or a redirect, and a 403 with Boot's default JSON. Article 20 gave every other error an RFC 9457 ProblemDetail with Content-Type: application/problem+json.

Why @RestControllerAdvice never sees them

The obvious attempt is two more handlers in the advice:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); 
 
    @ExceptionHandler(AuthenticationException.class) 
    public ProblemDetail handleAuthentication(AuthenticationException ex) { 
        log.warn("handleAuthentication called: {}", ex.toString()); 
        return ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED); 
    } 
 
    @ExceptionHandler(AccessDeniedException.class) 
    public ProblemDetail handleAccessDenied(AccessDeniedException ex) { 
        log.warn("handleAccessDenied called: {}", ex.toString()); 
        return ProblemDetail.forStatus(HttpStatus.FORBIDDEN); 
    } 

The two-chain runs in the previous section were made with these handlers already in the class: the 302 for /api/me, the 302 for the wrong password and the 403 with Boot's JSON were their results, and neither handleAuthentication called nor handleAccessDenied called appeared in the log. AuthenticationException and AuthorizationDeniedException are thrown and handled inside the security filters, before DispatcherServlet is called, and @ExceptionHandler methods only see exceptions from handler methods that DispatcherServlet called. Remove the two handlers; the place to shape these responses is the entry point and the access denied handler.

An entry point and an access denied handler that write ProblemDetail

One component implements both interfaces and writes the response itself, with the JsonMapper Boot configures for Jackson 3:

src/main/java/com/example/demo/common/ProblemDetailSecurityHandler.java
package com.example.demo.common;
 
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
 
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
 
    private final JsonMapper jsonMapper;
 
    public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"catalogue\"");
        write(request, response, HttpStatus.UNAUTHORIZED, "Valid credentials are required to access this resource.");
    }
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        write(request, response, HttpStatus.FORBIDDEN, "You are not allowed to perform this operation.");
    }
 
    private void write(HttpServletRequest request, HttpServletResponse response,
                       HttpStatus status, String detail) throws IOException {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setInstance(URI.create(request.getRequestURI()));
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        jsonMapper.writeValue(response.getOutputStream(), problem);
    }
}
  • commence is the AuthenticationEntryPoint method. It sets WWW-Authenticate itself, because a 401 without it tells the client nothing about how to authenticate.
  • handle is the AccessDeniedHandler method.
  • Neither calls sendError. The response is written and committed in place, so there is no ERROR dispatch for another chain to take over.
  • The detail is fixed. The exception message would tell a caller whether the user name exists or the password was wrong.

AccessDeniedException is the parameter type of the interface; AuthorizationDeniedException extends it. Wire the component into the API chain through exceptionHandling:

src/main/java/com/example/demo/common/SecurityConfig.java
    @Bean
    @Order(1)
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) { 
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) { 
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .exceptionHandling(exceptions -> exceptions 
                        .authenticationEntryPoint(problemHandler) 
                        .accessDeniedHandler(problemHandler)) 
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
Bash
curl -i http://localhost:8133/api/me
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
Content-Type: application/problem+json
Content-Length: 125
Date: Mon, 14 Sep 2026 04:29:12 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/me","status":401,"title":"Unauthorized"}
Bash
curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2
Text
HTTP/1.1 403
Content-Type: application/problem+json
Content-Length: 121
Date: Mon, 14 Sep 2026 04:29:14 GMT
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/products/2","status":403,"title":"Forbidden"}

Both bodies have the shape of article 20's handlers: the standard members in alphabetical order, the request path as instance, the reason phrase as title. A wrong password did not:

Bash
curl -i -u alice:wrong http://localhost:8133/api/me
Text
HTTP/1.1 302
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Set-Cookie: JSESSIONID=48EEB4984DDE4081E158FE00F5812246; Path=/; HttpOnly
Location: http://localhost:8133/login;jsessionid=48EEB4984DDE4081E158FE00F5812246
Content-Length: 0
Date: Mon, 14 Sep 2026 04:29:13 GMT

The entry point BasicAuthenticationFilter uses

realm="Realm" gives it away: that 401 came from the default BasicAuthenticationEntryPoint, not from the new component, and its sendError handed the request to the web chain again. exceptionHandling configures ExceptionTranslationFilter, and a wrong password never gets that far; BasicAuthenticationFilter calls the entry point of httpBasic itself. It needs the same component:

src/main/java/com/example/demo/common/SecurityConfig.java
                .httpBasic(Customizer.withDefaults()) 
                .httpBasic(basic -> basic.authenticationEntryPoint(problemHandler)) 
Bash
curl -i -u alice:wrong http://localhost:8133/api/me
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
Content-Type: application/problem+json
Content-Length: 125
Date: Mon, 14 Sep 2026 04:29:16 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/me","status":401,"title":"Unauthorized"}

In the same run, curl -i -u alice:wrong http://localhost:8133/api/products got the same 401 body with "instance":"/api/products", -H 'Accept: text/html' on /api/me got the same 401 instead of a redirect, and alice's DELETE still got the 403 above. Every security answer of the API is now a ProblemDetail, and the 401 keeps its WWW-Authenticate header.

In-memory users with InMemoryUserDetailsManager

spring.security.user.* configures one user. Two users with different roles need a UserDetailsService, and for now the in-memory one is enough. The finished SecurityConfig:

src/main/java/com/example/demo/common/SecurityConfig.java
package com.example.demo.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.core.userdetails.User; 
import org.springframework.security.core.userdetails.UserDetails; 
import org.springframework.security.provisioning.InMemoryUserDetailsManager; 
 
@Configuration
public class SecurityConfig {
 
    @Bean
    @Order(1)
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(basic -> basic.authenticationEntryPoint(problemHandler))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
 
    @Bean
    @Order(2)
    SecurityFilterChain webSecurityFilterChain(HttpSecurity http) {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults());
        return http.build();
    }
 
    @Bean
    InMemoryUserDetailsManager userDetailsService() { 
        UserDetails alice = User.withUsername("alice") 
                .password("{noop}alice-secret") 
                .roles("USER") 
                .build(); 
        UserDetails bob = User.withUsername("bob") 
                .password("{noop}bob-secret") 
                .roles("USER", "ADMIN") 
                .build(); 
        return new InMemoryUserDetailsManager(alice, bob); 
    } 
}

The spring.security.user.* lines come out of application.properties:

src/main/resources/application.properties
spring.application.name=demo
spring.security.user.name=alice 
spring.security.user.password=alice-secret 
spring.security.user.roles=USER 

roles("ADMIN") stores the authority ROLE_ADMIN, which is what hasRole("ADMIN") checks. The two users against the admin rule:

Bash
curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2
Text
HTTP/1.1 403
Content-Type: application/problem+json
Content-Length: 121
Date: Mon, 14 Sep 2026 04:29:20 GMT
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/products/2","status":403,"title":"Forbidden"}
Bash
curl -i -X DELETE -u bob:bob-secret http://localhost:8133/api/products/2
Text
HTTP/1.1 204
Date: Mon, 14 Sep 2026 04:29:20 GMT
Bash
curl -i -u bob:bob-secret http://localhost:8133/api/me
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 307
Date: Mon, 14 Sep 2026 04:29:21 GMT
 
{"name":"bob","authorities":["ROLE_ADMIN","ROLE_USER","FACTOR_PASSWORD"],"authenticationType":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principalType":"org.springframework.security.core.userdetails.User","credentialsPresent":false,"sameAsSecurityContextHolder":true}

What happens to the generated password

It is gone, and not because the properties were removed: the startup log of this run had no Using generated security password line at all, and the conditions report, with --debug, says why:

Text
   UserDetailsServiceAutoConfiguration:
      Did not match:
         - @ConditionalOnMissingBean (types: org.springframework.security.authentication.AuthenticationManager,org.springframework.security.authentication.AuthenticationProvider,org.springframework.security.core.userdetails.UserDetailsService,org.springframework.security.authentication.AuthenticationManagerResolver,org.springframework.security.oauth2.jwt.JwtDecoder; SearchStrategy: all) found beans of type 'org.springframework.security.core.userdetails.UserDetailsService' userDetailsService (OnBeanCondition)

Boot creates its user, generated or from spring.security.user.*, only while the application defines no UserDetailsService, AuthenticationProvider, AuthenticationManager, AuthenticationManagerResolver or JwtDecoder. With the bean above, the properties would be ignored even if they stayed.

Password prefixes and withDefaultPasswordEncoder()

Passwords stored for Spring Security's default DelegatingPasswordEncoder start with the id of the encoder that produced them: {noop} means the rest is plain text compared as is, {bcrypt} means the rest is a BCrypt hash. Boot relies on the same scheme for its own user: in UserDetailsServiceAutoConfiguration 4.1.1, a property password without an {id} prefix gets the constant NOOP_PASSWORD_PREFIX = "{noop}" put in front of it when the application has no PasswordEncoder bean. A startup probe run in the same application printed what each form does:

Text
2026-09-14T11:29:20.283+07:00  INFO 59512 --- [demo] [           main] com.example.demo.common.PasswordProbe    : withDefaultPasswordEncoder stores: {bcrypt}$2a$10$Kf8hsJdaA/IZKGvT28CM2u9QF1NZd2OOG4DJZQ5eS/qtJRq72dXr2
2026-09-14T11:29:20.283+07:00  INFO 59512 --- [demo] [           main] com.example.demo.common.PasswordProbe    : matches("bob-secret", "{noop}bob-secret") = true
2026-09-14T11:29:20.283+07:00  INFO 59512 --- [demo] [           main] com.example.demo.common.PasswordProbe    : matches without a prefix threw java.lang.IllegalArgumentException: Given that there is no default password encoder configured, each password must have a password encoding prefix. Please either prefix this password with '{noop}' or set a default password encoder in `DelegatingPasswordEncoder`.

User.withDefaultPasswordEncoder() hashes the password with BCrypt while the user is built, but it is marked deprecated in spring-security-core 7.1.1 (javap -v shows Deprecated: true), and its bytecode carries the warning "User.withDefaultPasswordEncoder() is considered unsafe for production and is only intended for sample applications": the plain password is still in the source code. {noop} is honest about the same thing and is fine for a demo. Article 34 moves the users into the database with BCrypt hashes.

Spring Security components at a glance

Every row is something this article ran into.

ComponentResponsibilityWhere you touch it
DelegatingFilterProxythe servlet filter Tomcat runs as springSecurityFilterChain, order -100; hands each request to the Spring beanspring.security.filter.order; normally never
FilterChainProxyruns the first SecurityFilterChain whose matcher matches the requestlogging.level.org.springframework.security.web.FilterChainProxy=TRACE
SecurityFilterChainan ordered list of filters for the requests its securityMatcher selectsa @Bean built from HttpSecurity, with @Order and securityMatcher
SecurityContextHolderFiltermakes the SecurityContext available for the requestsessionManagement(...) decides whether a session is used
CsrfFilterrejects requests other than GET, HEAD, TRACE and OPTIONS without a CSRF tokencsrf(...)
BasicAuthenticationFilterauthenticates the Authorization: Basic header; on failure calls its own entry pointhttpBasic(...)
UsernamePasswordAuthenticationFilterauthenticates POST /login from the login formformLogin(...)
AnonymousAuthenticationFiltersets anonymousUser with ROLE_ANONYMOUS when nobody authenticatedanonymous() rules match it
ExceptionTranslationFiltersends a denied anonymous caller to the entry point, a denied authenticated caller to the access denied handlerexceptionHandling(...)
AuthorizationFilterchecks the authorizeHttpRequests rules, first match winsrequestMatchers, permitAll(), anonymous(), hasRole(), authenticated(), anyRequest()
AuthenticationEntryPointanswers "who are you?" with a 401 challenge or a redirect to the login pageexceptionHandling(...) and httpBasic(...)
AccessDeniedHandleranswers "not allowed" with a 403exceptionHandling(...)
SecurityContextHolder, Authenticationthe current caller: principal, authorities, detailsAuthentication and @AuthenticationPrincipal controller parameters
UserDetailsServiceloads a user by name; its presence switches off Boot's userInMemoryUserDetailsManager bean here, the database in article 34
DelegatingPasswordEncodercompares passwords using the {id} prefix{noop} here, BCrypt in article 34

FAQ

Why does Spring Security return 401 instead of showing the login page?

Because the request did not ask for HTML. Boot's default chain has form login and HTTP Basic, and its entry point chooses by the Accept header: curl with Accept: */* or application/json got 401 with WWW-Authenticate: Basic realm="Realm", charset="UTF-8", while Accept: text/html got 302 to /login. A chain of your own with only httpBasic() answers 401 to both.

Is WebSecurityConfigurerAdapter available in Spring Security 7?

No. It was removed in Spring Security 6.0, and the spring-security-config 7.1.1 jar has no class of that name, no antMatchers or mvcMatchers, no authorizeRequests() and no and(). Security is configured by SecurityFilterChain beans built from HttpSecurity with lambdas, and requestMatchers replaces the old matcher methods.

Why does a POST return 403 or 401 with valid credentials?

CSRF protection is on by default and rejects every POST, PUT, PATCH and DELETE without a CSRF token, before the credentials are even checked. CsrfFilter answers 403, but with HTTP Basic the ERROR dispatch to /error runs anonymously, so a chain that does not permit /error turns it into a 401. The DEBUG line Invalid CSRF token found identifies the cause. For a stateless API that sends credentials in a header, disable CSRF; for anything that logs in with a cookie, send the token instead.

Can @RestControllerAdvice handle AccessDeniedException from Spring Security?

Not for rules checked by the security filters. The denial is thrown and handled inside the filter chain before DispatcherServlet runs; handlers for AuthenticationException and AccessDeniedException in the advice were never called in this article's runs. Shape these responses with an AuthenticationEntryPoint and an AccessDeniedHandler, and set the entry point on httpBasic(...) as well as on exceptionHandling(...).

What is the difference between permitAll() and anonymous()?

permitAll() lets every caller through. anonymous() lets through only a caller that AnonymousAuthenticationFilter marked as anonymousUser: GET /api/products with anonymous() answered 200 without credentials and 403 with alice's valid credentials. With either rule, a request carrying a wrong password is rejected with 401 by BasicAuthenticationFilter before the rule is checked.

What is FACTOR_PASSWORD in the authorities?

A FactorGrantedAuthority that Spring Security 7 adds to the Authentication to record that the caller authenticated with a password, together with the time it was issued. alice's /api/me response listed ["ROLE_USER","FACTOR_PASSWORD"] although she was created with only ROLE_USER. It does not change hasRole rules: alice still got 403 and bob 204 on DELETE with it in their authorities. Multi-factor authentication, @EnableMultiFactorAuthentication in 7.1.1, is a topic for the Advanced course.

Why does my second SecurityFilterChain never run?

Because an earlier chain matches its requests. FilterChainProxy uses only the first chain whose securityMatcher matches, in @Order. If the earlier chain has no securityMatcher, it matches any request, and Spring Security 7.1.1 refuses to start with UnreachableFilterChainException. Give the narrower chain the lower @Order value and leave the chain without securityMatcher last.

Conclusion

Spring Security is a servlet filter in front of Spring MVC: Tomcat calls DelegatingFilterProxy, which calls FilterChainProxy, which runs the first SecurityFilterChain that matches the request and its ordered filters before DispatcherServlet sees anything. The starter alone locks every endpoint behind a generated user, answering 401 to an API client and redirecting a browser to a login page. A SecurityFilterChain bean of your own replaces Boot's chain entirely, needs no @EnableWebSecurity, and in Spring Security 7.1.1 is written only with the lambda DSL. Authentication failures reach the AuthenticationEntryPoint as 401, authorization failures reach the AccessDeniedHandler as 403, and ExceptionTranslationFilter chooses between them from the caller in SecurityContextHolder.

The traps are about order and dispatches. Rules and chains are both first match wins, and a rule order can silently open a DELETE to anyone. CSRF rejects a POST before its credentials are checked, and the ERROR dispatch can turn that 403 into a 401, or a second chain can turn an API's 401 into a redirect. An entry point and an access denied handler that write ProblemDetail themselves, set on both httpBasic and exceptionHandling, give the API consistent 401 and 403 answers with WWW-Authenticate intact. STATELESS keeps sessions away from it, and a UserDetailsService bean replaces Boot's generated user.

The users still live in code with {noop} passwords. The next article authenticates users from the database: a UserDetailsService that loads them from there, a PasswordEncoder with BCrypt, and registration and login.

Related Posts

[Spring Boot Basics] Spring Beans and the ApplicationContext: @Component, Stereotypes and Component Scanning

What a Spring bean actually is and which of your objects should never be one, @Component proved to be the meta-annotation behind @Service, @Repository and @Controller, what each stereotype really adds at run time, how component scanning turns class files into BeanDefinitions, the bean-naming rule including the two-capitals case, the ApplicationContext API with the bean count of a Spring Boot 4.1.1 app, and three registration failures with their error messages.

[Spring Boot Basics] Spring Data JPA Queries: Derived Query Methods, @Query with JPQL and Native SQL

Spring Data JPA queries on Spring Boot 4.1.1, with H2 and PostgreSQL: how a derived query method name is parsed into JPQL and SQL, a keyword table with the SQL each keyword generated, return types and the exceptions they throw, the startup failure for a misspelled property, whether Containing escapes % and _, @Query with JPQL and when @Param is needed, native full-text search that fails on H2, SQL injection through string concatenation, and @Modifying bulk updates with the stale persistence context, the stale value written back on flush, and clearAutomatically.

[Spring Boot Basics] application.properties vs application.yml in Spring Boot: Syntax and @Value

application.properties and application.yml in Spring Boot 4.1.1: the .properties syntax rules, the ISO-8859-1 default that mangles Vietnamese text, YAML nesting and the SnakeYAML values that silently change type, which file wins when both exist, placeholders and random values, and @Value with defaults, type conversion, SpEL and the Environment.

[Spring Boot Basics] Spring Boot Project Structure: Gradle vs Maven, @SpringBootApplication and the Executable JAR

Every file Spring Initializr generates on Spring Boot 4.1.1: the directory layout, the Gradle and Maven wrappers, build.gradle line by line against the same pom.xml, a fair Maven vs Gradle comparison, the BOM that removes version numbers, @SpringBootApplication decomposed into its three meta-annotations with the base-package 404 reproduced, and the anatomy of the executable fat JAR.