1. Introduction
We have already seen the limit of URL-only protection: SecurityFilterChain cuts off requests nicely, but by itself it does not make publish(...), reject(...), or lockUser(...) safe. Now we are turning this into a working project checkpoint and moving precise rules to the place where the system state actually changes — into the public entry methods of services.
In the Secure Content Platform API, there is no need to annotate everything right away. It is enough to take explicit privileged operations: the editor flow (loadQueue, publish, reject) and the admin flow (listUsers, lockUser, changeRoles). Controllers stay thin, the URL layer keeps the entry zones, and the service layer gets precise capability-level rules.
2. Endpoint -> service -> access map
Before writing @PreAuthorize, it is useful to make a small “map of the territory.” Otherwise annotations appear randomly: one is added in a controller, another in a service, a third is forgotten, and a fourth is written with a typo, and everything works... until it needs to be maintained two weeks later. A map helps you think not in annotations, but in operations: what business operation is being performed and what permission it requires.
A mental model of the flow with method security is conveniently shown like this (as a “thin tunnel” inside the application):
flowchart TD
A[HTTP request] --> B[SecurityFilterChain
request-level rules]
B --> C[Controller]
C --> D["Service proxy
(AOP)"]
D --> E["@PreAuthorize check"]
E -->|OK| F[Service method body]
E -->|DENY| G[AccessDeniedException]
Now let us make a practical table. Two things matter here: the request-level rule remains an “entry filter,” and the method-level rule is a “lock on a specific door.”
| Endpoint | Controller method | Service method (entry) | Request-level rule | Method-level rule |
|---|---|---|---|---|
| GET /api/editor/review-queue | EditorController.reviewQueue() | ReviewService.loadQueue() | authenticated() | hasAuthority('draft:review') |
| POST /api/editor/drafts/{id}/publish | EditorController.publish(id) | ReviewService.publish(id) | authenticated() | hasAuthority('draft:publish') |
| POST /api/editor/drafts/{id}/reject | EditorController.reject(id) | ReviewService.reject(id) | authenticated() | hasAuthority('draft:review') |
| GET /api/admin/users | AdminController.listUsers() | AdminUserService.listUsers() | authenticated() | hasAuthority('user:read') |
| PATCH /api/admin/users/{id}/lock | AdminController.lockUser(id) | AdminUserService.lockUser(id) | authenticated() | hasAuthority('user:manage') |
| PATCH /api/admin/users/{id}/roles | AdminController.changeRoles(id) | AdminUserService.changeRoles(id) | authenticated() | hasAuthority('user:manage') |
In this map, @PreAuthorize is applied to the public entry methods of Spring beans that are called from outside the bean. Helper/private methods are not a security boundary: the proxy cannot see them, and self-invocation can easily bypass the check. If one process needs two different protected entry points, it is better to keep them in different beans and move shared logic into a helper without annotations.
Notice the style: the request-level rule here is intentionally “coarse” — it does not try to express the whole business logic, but simply guarantees that an anonymous user will not enter this area. The method-level rule is already precise: it operates with the same authority strings that we have established in the project (draft:review, draft:publish, user:read, user:manage). And the best part is that if tomorrow we add a new endpoint that calls the same service method, the rule will apply automatically — because it lives on the business action, not on the URL.
3. Editor flow in ReviewService
Now comes the moment when the project starts looking “grown up,” but without the tragedy of an 800-line enterprise configuration. We take the editor operations and move the rules to where they logically belong: into the service, next to the code that changes the content status. This solves two problems at once: there is no need to duplicate the same rule in multiple controllers, and there is no way to accidentally expose a critical action through a new URL without protection.
Let us start with the service. It is important that this is a Spring bean (for example, @Service), and that the annotated methods are public — these are the entry methods where the proxy can actually “hook in” and perform the check.
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class ReviewService {
// Check permission to view/moderate drafts before business logic runs
@PreAuthorize("hasAuthority('draft:review')")
public void loadQueue() {
// Logic for finding SUBMITTED materials goes here
// Important: if there is no authority, the method body will not even start executing
}
// Publishing is a separate, stronger authority
@PreAuthorize("hasAuthority('draft:publish')")
public void publish(long draftId) {
// Status changes to PUBLISHED happen here
// This is exactly why protection on the method is critical: we are changing system state
}
// Rejection is closer to moderation in meaning, so the same authority as for the queue
@PreAuthorize("hasAuthority('draft:review')")
public void reject(long draftId) {
// Status changes to REJECTED happen here
}
}
These expressions are deliberately short. We are not turning @PreAuthorize into a tiny SpEL novel, because the goal of this lecture is to move “who is allowed to perform the operation at all.” Internal details, such as checking the current draft status, validating the transition from SUBMITTED to PUBLISHED, or handling a moderation comment, are business logic. They may and should live inside the method (or inside the domain layer), but not in the annotation string.
Now the controller. It becomes “thin”: it does not make access decisions and it does not try to perform a security check with an if. It simply forwards the command to the service.
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EditorController {
private final ReviewService reviewService;
public EditorController(ReviewService reviewService) {
// Spring injects the dependency; this matters so that method security works through a proxy
this.reviewService = reviewService;
}
@PostMapping("/api/editor/drafts/{id}/publish")
void publish(@PathVariable long id) {
// The controller does not make access decisions — it only calls the business operation
reviewService.publish(id);
}
}
As a result, the behavior becomes very predictable. An anonymous user will not pass the request-level barrier and will receive 401. A regular authenticated USER will reach the controller, but will hit method security and receive 403. An editor or administrator (who has the required authority) will successfully perform the operation, and the method will actually start changing state.
This creates a nice “seat belt effect.” If tomorrow someone adds another controller method like @PostMapping("/api/admin/drafts/{id}/publish") that also calls reviewService.publish(id), then publish will remain protected — because the lock is on the door, not on the sign that says “entrance from the street.”
4. Admin flow in AdminUserService
The situation with admin operations is even more illustrative: people love adding “convenient” endpoints for administration, especially “temporarily on localhost,” and then that “temporary” thing suddenly lives for a year and appears in releases. That is why method-level protection on admin services is a must-have, even in a learning project. And yes, this is exactly the case where it is better to be safe and add a “double lock” than later explain why an ordinary user suddenly managed roles.
Let us split permissions into two levels, as we already agreed in the project authority model: reading users (user:read) and managing them (user:manage). Then we protect the user list more softly, and locking and role changes more strictly.
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class AdminUserService {
// Soft permission: it is enough to be able to read users
@PreAuthorize("hasAuthority('user:read')")
public void listUsers() {
// Loading the list of users (for example, from a repository)
}
// Strict permission: this is already account management
@PreAuthorize("hasAuthority('user:manage')")
public void lockUser(long userId) {
// Account locking
// Here it is especially important not to leave this only at the URL level
}
// Strict permission: role changes directly affect security
@PreAuthorize("hasAuthority('user:manage')")
public void changeRoles(long userId) {
// Role changes
}
}
Yes, the methods are empty — and that is normal for a lecture: what matters here is the security structure, not the repository implementation. When you integrate this into a project, listUsers() may return DTOs, lockUser(...) will do an UPDATE, and changeRoles(...) will change the set of roles, but the access rules will remain as readable as road signs.
And again, the controller remains a “wire.” It does not need to know what user:manage is. It only needs to accept input, call the service, and return the HTTP response correctly.
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AdminController {
private final AdminUserService adminUserService;
public AdminController(AdminUserService adminUserService) {
// Important: injection through Spring, not manual creation via new
this.adminUserService = adminUserService;
}
@PatchMapping("/api/admin/users/{id}/lock")
void lockUser(@PathVariable long id) {
// The controller "just calls"; all protection for the operation lives in the service
adminUserService.lockUser(id);
}
}
This may look too “simple,” but that is the point. The controller is about HTTP. The service is about the business action. Security for the business action should be close to the service, because that is where we protect “what can be done,” not “which URL happened to be hit.”
5. Combining request-level and method-level rules
When method security starts working, a beginner usually has two extreme desires. The first is to remove request-level rules as “unnecessary.” The second is to duplicate absolutely everything: “just in case” put hasAuthority(...) in SecurityFilterChain and the same hasAuthority(...) on the method. Both desires are understandable, but both lead to pain. In our project, it is better to keep a simple agreement: request-level rules are responsible for zones and the basic perimeter (public vs private), while method-level rules are responsible for operations.
This fragment of SecurityFilterChain does not claim to be the final configuration of the application and does not try to express all business rules through URLs again. It exists to show the new split responsibility of two layers: the request-level layer keeps the public/private zones, while the exact permissions live on service methods.
Here is an example of a “reasonably coarse” SecurityFilterChain fragment for today’s task. It does not express who may publish; it expresses that the editor and admin areas are not for anonymous users.
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
// request-level layer: cut off anonymous users from private zones by URL
http.authorizeHttpRequests(auth -> auth
// Public endpoints: access without login
.requestMatchers("/api/public/**", "/api/auth/**").permitAll()
// Private zones: any authenticated user is required
.requestMatchers("/api/editor/**", "/api/admin/**").authenticated()
// Close everything else so we do not leave “accidentally open” endpoints
.anyRequest().denyAll()
);
With this approach, your @PreAuthorize becomes the main “description of access” to the operation, and it is easier to read in code. At the same time, the request-level layer is still useful: it saves resources (anonymous users do not hit unnecessary controllers), gives the nice 401 semantics at the filter level, and keeps the project disciplined, where something private does not become public by accident.
Notice the boundary of this checkpoint: hasAuthority('draft:publish') answers the question of who can publish at all, and hasAuthority('user:manage') answers who can manage accounts at all. But such rules do not yet answer whether you can work with this specific draft or this specific user. As soon as the rule depends on an object, not just on a capability, you will need the current user and an owner-based check.
There is another practical problem that you will feel literally after a couple of annotations: strings like 'draft:publish' are not checked by the compiler. A typo turns the method into “always forbidden.” To avoid bugs like draft:publsih (a classic), you can introduce constants. This is not mandatory, but it helps a lot in a learning project.
package com.example.securecontent.security;
public final class Authorities {
// A single place where authority strings live so they are not spread all over the code
public static final String DRAFT_REVIEW = "draft:review";
public static final String DRAFT_PUBLISH = "draft:publish";
public static final String USER_READ = "user:read";
public static final String USER_MANAGE = "user:manage";
private Authorities() {
// Prevent instantiation: this is a utility class with constants
}
}
Then the annotation becomes a little safer for the eyes. In T(...) you usually specify the full class name so that SpEL can find the needed type exactly.
@PreAuthorize("hasAuthority(T(com.example.securecontent.security.Authorities).DRAFT_PUBLISH)")
public void publish(long draftId) { }
This already looks a bit more SpEL-like, but if T(...) still feels scary, you can start without it and move to constants later. The main thing is to keep expressions short and consistent, and not to spread access logic across controllers and ifs.
6. Typical mistakes when moving rules
At this stage everything starts working, and that is exactly why mistakes become especially tricky: the system is “almost ready,” and the bug is somewhere between a line in an annotation and the way a bean is actually created. It is important to learn to recognise such problems by their symptoms: “why do I always get 403,” “why is the annotation ignored,” “why did it work in one place but not in another.” Below are the most common pitfalls that almost everyone encounters when they first move access rules into the service layer.
Mistake #1: duplicating the same rule in both SecurityFilterChain and @PreAuthorize, and then forgetting to update one of them.
This usually happens “out of caution”: you put hasAuthority('draft:publish') on the URL and the same one on the method. As a result, when the permission model changes, you fix two places and get inconsistency. It is much more robust to keep the request-level layer coarse (public vs authenticated zones) and protect exact operations in the service.
Mistake #2: a typo in the authority name, which makes the method “forbidden forever.”
hasAuthority('user:manange') looks almost correct, but for Spring Security it is a different string, and access will always be denied. The most unpleasant part is that the compiler stays silent. If you see a constant 403 even for an administrator, first check the exact permission strings and their mapping in UserDetails.
Mistake #3: the annotation is placed where the real entry into the operation does not pass.
For example, you annotated a small helper method inside the service, but the public entry method remained without @PreAuthorize. Or vice versa: you call a protected method from another method of the same class (self-invocation), and the proxy does not have time to insert the check. Externally it looks like “the annotation is there,” but in fact it is not applied to the real call.
Mistake #4: the service was created manually with new instead of by the Spring container.
Method security works only on beans that Spring creates and wraps in a proxy. If somewhere you did new ReviewService(...), then no @PreAuthorize will work, because the proxy simply was not involved. In learning projects, this often happens by accident when someone “quickly” creates an object incorrectly in configuration or in test code.
Mistake #5: confusion between roles and authorities, especially around the ROLE_ prefix.
If you write hasRole('ROLE_ADMIN'), that is almost always a mistake: hasRole expects the name without ROLE_, because the prefix is added automatically. And if you use hasAuthority, then prefixes are not added there, and the string must match exactly. In practice it is easier to agree on this: use roles for coarse zones, authorities for specific operations, and do not mix them into one pile.
GO TO FULL VERSION