Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

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();
}
LineMeaning
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
STATELESSThe 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/1
HTTP/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 403

The 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 anyRequest

A 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").

Thanks for reading!

Donate to the site — we work hard for you.