API access rules
We have users; now we need rules: who may do what. Anyone may read posts, only logged-in users may write, only an admin may delete. Rules are set in a SecurityFilterChain bean: a chain of filters every request passes through.
A chain for the API
// src/main/java/com/example/blog/SecurityConfig.java
@Bean
@Order(1)
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/users").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}| Line | Meaning |
|---|---|
securityMatcher("/api/**") | The chain handles only API addresses |
requestMatchers(GET, "/api/posts/**").permitAll() | Posts and comments can be read without logging in |
hasRole("ADMIN") | Only the ADMIN role may delete posts |
anyRequest().authenticated() | Everything else — after logging in |
httpBasic(...) | Name and password with every request |
STATELESS | The server keeps no session: an API client sends the password each time |
csrf.disable() | A CSRF attack targets browser cookies, and the API uses no cookies |
Rules are checked top down, and the first match wins. @Order(1) will matter in the next lesson, when a second chain appears — for the site pages.
Who made the request
The post's author is now the logged-in user, not a number from the request. We remove the authorId field from NewPostRequest, and the controller learns the user through @AuthenticationPrincipal:
// src/main/java/com/example/blog/PostController.java
@PostMapping
public ResponseEntity<PostResponse> create(@Valid @RequestBody NewPostRequest request,
@AuthenticationPrincipal UserDetails user) {
PostResponse post = postService.create(request, user.getUsername());
return ResponseEntity.created(URI.create("/api/posts/" + post.id())).body(post);
}// src/main/java/com/example/blog/PostService.java
@Transactional
public PostResponse create(NewPostRequest request, String username) {
User author = users.findByUsername(username)
.orElseThrow(() -> new IllegalArgumentException("Unknown author " + username));
Post post = new Post(request.title(), request.body(), author, Instant.now(clock));
return PostResponse.from(posts.save(post));
}UserDetails is the very object BlogUserDetailsService returned at login. Comments change the same way: NewCommentRequest keeps a single text field.
Checking it
curl -i -X POST http://localhost:8080/api/posts -H "Content-Type: application/json" \
-d '{"title":"Hello","body":"x"}'
curl -X POST -u anna:changeme http://localhost:8080/api/posts -H "Content-Type: application/json" \
-d '{"title":"Hello, Spring Security","body":"Posts now have a real author"}'
curl -i -u anna:changeme -X DELETE http://localhost:8080/api/posts/1HTTP/1.1 401
{"id":1,"title":"Hello, Spring Security","body":"Posts now have a real author","author":"anna","createdAt":"2026-10-04T09:04:38.954220400Z"}
HTTP/1.1 403The guest got 401, Anna created a post but cannot delete it: 403 — she is logged in but lacks the ADMIN role. Let's make Anna an admin right in the database:
update users set role = 'ADMIN' where username = 'anna';After that the same delete answers 204. The role is read at every login, so no restart is needed.
Common mistake
The anyRequest() rule must come last. If you put other rules after it, the application does not start:
Can't configure requestMatchers after anyRequestA similar trap is hasRole("ROLE_ADMIN"). Spring adds the ROLE_ prefix itself, so you get the error ROLE_ADMIN should not start with ROLE_ since ROLE_ is automatically prepended. Just write hasRole("ADMIN").