1770 단어
9 분
Spring Security Authorization in Practice

Introduction#

While building an API server for a product that was headed for release, I quickly realized something: there’s a big difference between developing for a demo project and preparing for a real-world service. One of the biggest differences was authorization.

In previous projects, using Spring Security typically meant implementing authentication—verifying that a user is who they claim to be. That was usually enough for school or team projects. But when it came to building something for actual users, I had to ask myself:

“What happens if a user tries to read, modify, or delete someone else’s data?”

That’s where authorization comes in—controlling what an authenticated user can and cannot do. In this post, I’ll walk through how Spring Security handles authorization, how I designed the structure, and how I implemented and improved it in a production-level service.


The Big Picture: Spring Security Flow#

Authentication Flow

Spring Security is a framework responsible for securing the entire application—both authentication and authorization. The first component that handles every HTTP request is the FilterChain, which routes requests through a series of filters.

These filters perform authentication (who are you?) and authorization (can you do this?) in order. If any filter determines that the request fails a security check, it stops the request and returns an appropriate response (e.g., redirect to login or a 403 Forbidden).

This is the typical flow:

  1. A request comes into the application.
  2. The request goes through Spring Security’s filter chain, which passes it to the AuthenticationManager.
  3. If authentication and authorization are successful, the security context is populated.
  4. The request proceeds to the Spring MVC DispatcherServlet and is routed to the appropriate controller.

Let’s first take a deeper look at authentication and the SecurityContext.


Understanding SecurityContextHolder and Authentication#

Authentication Flow

Here’s a basic example of how to access the authenticated user:

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

SecurityContextHolder#

SecurityContextHolder holds the SecurityContext, which contains details about the current user. By default, it uses a ThreadLocal strategy, meaning that each thread has its own independent security context. This allows you to access user information from anywhere within the same thread, without passing it around.

It supports three modes:

  1. MODE_THREADLOCAL (default): separate context per thread.
  2. MODE_INHERITABLETHREADLOCAL: child threads inherit context.
  3. MODE_GLOBAL: all threads share the same context (rarely used).

SecurityContext & Authentication#

From the SecurityContext, you can get the Authentication object.
This object contains:

  • the authenticated user (principal)
  • their roles or permissions (authorities)
  • their name or ID (getName())

The AuthenticationManager is responsible for performing the actual check and populating this object if successful.


Authentication Flow in Detail#

AbstractAuthenticationProcessingFilter is the core filter that handles user credentials in Spring Security. Before this filter comes into play, Spring usually triggers an AuthenticationEntryPoint to prompt the user for credentials (e.g., login page or header prompt for tokens).

Let’s break it down:

Authentication Flow

  1. The user submits credentials (e.g., username and password).
  2. A filter like UsernamePasswordAuthenticationFilter reads these and creates an Authentication object.
  3. The object is passed to the AuthenticationManager for verification.
  4. Depending on the outcome:
    • Failure:
      • Clears the security context
      • Triggers AuthenticationFailureHandler
      • May redirect or return an error
    • Success:
      • Sets the authenticated user in SecurityContextHolder
      • Notifies session and remember-me mechanisms
      • Fires success events

In most cases, you don’t have to implement all of this manually. But for token-based auth (like JWT), custom filters are often required.


Custom TokenAuthenticationFilter#

Here’s an example of a custom filter I implemented to support JWT authentication.

@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    // ...
    @Override
    protected void doFilterInternal(...) throws ServletException, IOException {
        String tokenStr = HeaderUtil.getAccessToken(request);
        if (StringUtils.hasText(tokenStr)) {
            try {
                AuthToken token = tokenProvider.convertAuthToken(tokenStr);
                if (token.validate()) {
                    Authentication authentication = tokenProvider.getAuthentication(token);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                } else {
                    handleAuthenticationError(...);
                    return;
                }
            } catch (ExpiredJwtException e) {
                handleAuthenticationError(...);
                return;
            } catch (Exception e) {
                handleAuthenticationError(...);
                return;
            }
        }
        filterChain.doFilter(request, response);
    }
    // ...
}

This filter extracts the JWT from the request, validates it, and if successful, sets the user in the security context.


Authorization in Spring Security#

Once authentication succeeds, the next step is authorization—determining what the user can actually do. This is handled by AuthorizationFilter, which checks the authenticated user’s roles or permissions from SecurityContextHolder.

Authentication Flow

Basic Flow:#

  1. AuthorizationFilter gets the Authentication object.
  2. It checks the user’s roles against the defined access rules using AuthorizationManager.
  3. If unauthorized, an AccessDeniedException is thrown and handled.
  4. If authorized, the request proceeds to the controller.

Authorization Strategies: URL vs. Method Level#

Spring Security provides two main styles of authorization:

URL-based Authorization#

Using antMatchers() or requestMatchers() in SecurityFilterChain:

http.authorizeRequests()
    .antMatchers("/admin/**").hasRole("ADMIN")
    .antMatchers("/user/**").hasRole("USER")
    .antMatchers("/public/**").permitAll();

Best for global access policies.


Method-based Authorization#

Using annotations like @PreAuthorize:

@PreAuthorize("hasRole('ADMIN')")
public void adminOnlyAction() { ... }

@PreAuthorize("hasRole('USER') and #userId == principal.userId")
public void userSpecificAction(Long userId) { ... }

Best for fine-grained, business-specific rules.


Role vs. Authority#

In Spring Security:

ConceptDescription
RoleBroad user type (e.g., ROLE_USER, ROLE_ADMIN)
AuthoritySpecific privileges (e.g., READ_FEED, WRITE_COMMENT)

You can define both in your JWT and decode them on the server:

authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
authorities.add(new SimpleGrantedAuthority("WRITE_BOARD"));

Designing Roles and Permissions#

When I first implemented authentication in my service, I hadn’t configured any authorization logic. As I moved closer to a real deployment, it became clear that I needed to define a proper access control model.


Login Service Architecture#

Login Flow

In our flow, even password resets use a temporary token (TempTokenService) to issue a one-time credential for safe operations.


Authorization Scenarios#

There are two major categories to consider:

1. Sign-up and Password Reset#

Guest Token Flow

Temporary tokens used in these cases should only be allowed to call signup or password endpoints. They should not be able to access or modify any user data.

2. General Usage#

Data Access Rules

  • Users can create, update, and delete their own data.
  • They should not be able to access others’ data—except for reading public info in some cases.

Access Strategy#

Initially, I thought about enforcing permissions like this:

  1. Check user role first (ROLE_USER)
  2. Check if the user is the resource owner via authority
  3. Use @PreAuthorize + database checks instead (this is more accurate and secure)

(Yes, the strikethrough is intentional—I changed direction mid-design…)


Adding Roles and Authorities to JWT#

Previously, I only encoded user roles in the token. Now I updated my JWT structure to include both roles and authorities to improve scalability.

AuthToken and AuthTokenProvider#

I extended both classes to support dynamic permission injection via claims.

// Role claim: "role"
// Authority claim: "permissions"
.claim("role", role)
.claim("permissions", permissions)

This allows us to later introduce fine-grained access control like READ_FEED, WRITE_COMMENT without changing the JWT structure.


Defining Roles#

public enum RoleType {
    USER("USER", "Standard user"),
    ADMIN("ADMIN", "Administrator"),
    GUEST("GUEST", "Guest access");

    public static RoleType of(String code) { ... }
}

Temporary tokens are issued with the GUEST role and are only valid for signup and password reset endpoints.


Guest Access Policy#

private static final String[] GUEST_PERMIT_URL_ARRAY = {  
        "/member/password",  
        "/member/join"  
};

@Bean  
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {  
	// ...
    http.authorizeHttpRequests((authorizeHttp)-> authorizeHttp  
            // ...
            .requestMatchers(GUEST_PERMIT_URL_ARRAY).hasAnyRole(RoleType.GUEST.getCode(), RoleType.USER.getCode(), RoleType.ADMIN.getCode())  
            // ...
            .anyRequest().hasAnyRole(RoleType.USER.getCode(), RoleType.ADMIN.getCode()));
    // ...
  
    http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);  
    return http.build();  
}

Only the following URLs are accessible to GUESTs:

  • /member/join – Sign-up
  • /member/password – Password reset

All other endpoints require authenticated users (USER or ADMIN).


Enabling Method-Level Authorization#

To use annotations like @PreAuthorize and @PostAuthorize, we enable method-level security:

@Configuration  
@RequiredArgsConstructor  
@EnableWebSecurity  
@EnableMethodSecurity(prePostEnabled = true)  
public class SecurityConfig {

This activates Spring Security’s method-level authorization.

  • @PreAuthorize: evaluates before method execution.
  • @PostAuthorize: evaluates after, based on the return value.

First Attempt (That Didn’t Work)#

Initially, I tried to verify user ownership like this:

@PostMapping  
@PreAuthorize("@memberSecurity.isCurrentUser(#request)")  
public ResponseEntity<?> registerPersonalInfo(...) { ... }

Here, I created a custom component called memberSecurity to manually extract and validate the user’s identity from the token in the request:

@Component("memberSecurity")  
public class MemberSecurity {
    public boolean isCurrentUser(HttpServletRequest request) {
        String token = request.getHeader("Authorization");
        AuthToken authToken = tokenProvider.convertAuthToken(token);
        Authentication auth = tokenProvider.getAuthentication(authToken);
        Authentication contextAuth = SecurityContextHolder.getContext().getAuthentication();
        return auth.getName().equals(contextAuth.getName());
    }
}

But I soon realized this check is completely redundant.

Why?

Because once JWT authentication is successful, Spring Security already populates the SecurityContext with the authenticated user’s identity.
There’s no need to re-parse the token manually.

In other words:

If you’re authenticated, the system already knows who you are.

So this code does nothing meaningful, and worse, it adds unnecessary complexity.


The Real Problem: Ownership of Resources#

What I truly needed wasn’t “Is this user authenticated?”
It was:

“Does this user actually own the data they’re trying to access?”

Authorization Result

Suppose we want to update an existing feed. To do so, we need to know the feed’s ID, which is typically passed in through the request body or as a URL parameter. However, the only information available at login is something like member_id = 1. And since the method receives only a HttpServletRequest, we don’t have direct access to the feedId—or even if we do, it’s hard to extract and verify it before the method runs.

In the end, to determine whether a feed belongs to the currently authenticated user (e.g., member_id = 1), we must check the database.

There are two ways to handle this:

Perform ownership validation inside the business logic layer, or

Redesign memberSecurity to accept feedId instead of HttpServletRequest, allowing it to query the database and verify ownership.

Using the feedId, we can query the database to confirm whether the feed is associated with the current user.


The Fix: socialSecurity Component#

To solve this, I created a new component called socialSecurity.
This component performs a database lookup to verify ownership:

@Component("socialSecurity")  
@RequiredArgsConstructor  
public class SocialSecurity {
    private final MemberService memberService;
    private final FeedRepository feedRepository;

    public boolean isFeedMemberProperty(Long feedId) {
        Member member = memberService.getMemberUsingSecurityContext();
        return feedRepository.existsByIdAndMember(feedId, member);
    }
}

Then I applied it like this:

@PutMapping("/feed/{feedId}")  
@PreAuthorize("@socialSecurity.isFeedMemberProperty(#feedId) or hasRole('ADMIN')")  
public ResponseEntity<?> editFeed(...) {
    ...
}

This ensures that only the owner of the feed—or an admin—can modify it.


Refactoring Ownership Checks#

To properly enforce ownership of entities (e.g., Feed), I created a new class:

@Component("socialSecurity")
public class SocialSecurity {
    public boolean isFeedMemberProperty(Long feedId) {
        Member member = memberService.getMemberUsingSecurityContext();
        return feedRepository.existsByIdAndMember(feedId, member);
    }
}

Then I used it in @PreAuthorize like this:

@PreAuthorize("@socialSecurity.isFeedMemberProperty(#feedId) or hasRole('ADMIN')")

This ensures that only the owner (or an admin) can edit the feed.


Result#

After switching to @socialSecurity with DB ownership checks, everything worked as expected:

Authorization Result

Unauthorized users receive proper error responses when trying to access someone else’s feed.


Key Takeaway#

It’s not enough to just know who the user is—you have to connect that identity to ownership of resources, and that often means hitting the database.

The memberSecurity approach failed because it was checking token identity (already handled).
The socialSecurity approach succeeded because it verified domain-level ownership, which is the real authorization concern.

Conclusion#

In this journey, I went from simply issuing roles in tokens to building a fine-grained access system using method-level security, role checks, and real ownership validation through the database.

What took the most time wasn’t writing code—it was understanding what to restrict, why to restrict it, and how to reflect it in the domain logic.

There’s still more to do—optimizing queries, cleaning up APIs—but this was a huge step forward in building a secure, production-ready backend.

you can see the code in

Team-SynApps
/
resona-api-server
Waiting for api.github.com...
00K
0K
0K
Waiting...

References#

Spring Security Authorization in Practice
https://blog-full-of-desire-v3.vercel.app/posts/auth/spring-security-authorization-en/
저자
SpeculatingWook
게시일
2025-04-06
라이선스
CC BY-NC-SA 4.0