한국어 | English | 日本語
Senior Web Application Developer (8.8+ years)
Tech & Dev
engineering
Focusing on web frontend and backend development

Spring Security: SecurityContextHolder's Thread Sharing Strategies

I encountered a peculiar bug where Spring Security session information intermittently vanished within a `parallelStream` introduced for parallel processing. Like 'Schrödinger's Cat,' the results varied with each refresh. This post delves into the cause of this phenomenon and explores the three strategies SecurityContextHolder uses to share session data in a multi-threaded environment.
This article analyzes why permission checks failed in only some threads when `parallelStream` was applied to list filtering logic. It diagnoses the issue caused by `SecurityContextHolder`'s default `MODE_THREADLOCAL` strategy failing to share context with child threads, and examines the characteristics and caveats of different sharing modes, such as `MODE_INHERITABLETHREADLOCAL`, to resolve this.

I needed to implement a process on a page displaying multiple pieces of information in a list, where some information would be hidden based on the logged-in user’s permissions. My approach was to first retrieve the list from an API, then filter some of this information using the roles stored in the current Spring Security login session, and finally render the processed list on the display page.

However, I encountered a strange bug: if there were 10 rows displayed in the list, the ‘session permission filtering’ logic was only applied to approximately 2 to 3 rows (about 1/4), while the remaining 3/4 of the rows were unaffected. What was even stranger was that the specific 1/4 of rows that were affected changed unpredictably with each refresh. For example, after one refresh, the filtering might apply to the 2nd and 3rd rows, but after another refresh, it might apply to the 5th and 6th rows. It was like Schrödinger’s Cat…

The implementation was as follows:

List<SomeInformation> list = someApi.retreive(condition);
list.parallelStream()
    .forEach(each -> {
        if (!SecurityHelper.hasRole("ROLE_CAN_SEE_SENSITIVE_NUMBERS")) {
            each.setSensitiveNumber1(null);
            each.setSensitiveNumber2(null);
        }
    })
public class SecurityHelper {

    public static boolean hasRole(String role) {
        SecurityContext context = SecurityContextHolder.getContext();
        if (Objects.isNull(context))) {
            return false;
        }
        Authentication authentication = context.getAuthentication();
        if (Objects.isNull(authentication))) { // Corrected 'auth' to 'authentication'
            return false;
        }
        for (GrantedAuthority eachAuthority : authentication.getAuthorities()) { // Corrected 'GrantAuthority' to 'GrantedAuthority'
            if (role.equals(eachAuthority.getAuthority())) {
                return true;
            }
        }
        return false;
    }
}

Actually, the login account used for testing had the ROLE_CAN_SEE_SENSITIVE_NUMBERS permission, so both sensitiveNumber1 and sensitiveNumber2 should have been displayed normally for all rows in the list. However, only 1/4 showing up was very odd, so I added logs inside the parallelStream.forEach block, which yielded the following results:

INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-3] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-7] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-1] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-4] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-5] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-6] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true

It appears that hasRole returns false unexpectedly in the child threads (ForkJoinPool.commonPool-worker-1~7) allocated for parallelStream execution (i.e., the ForkJoinPool), while it returns true correctly in the main thread (http-nio-80-exec-3). This strongly suggests an issue with combining parallelStream and SecurityContextHolder.

SecurityContextHolder’s Thread Sharing Modes

The primary reason hasRole = false was returned in the parallelStream worker threads was that SecurityContextHolder.getContext() returned null. In contrast, when SecurityContextHolder.getContext() was called in the main thread, it successfully retrieved session data and proceeded with the appropriate permission check logic for hasRole. Upon further investigation, I discovered the following:

SecurityContextHolder allows you to specify the mode for sharing SecurityContext login session information across threads. The default value is MODE_THREADLOCAL, which means SecurityContext information is only visible to the “main thread”.

There are a total of three sharing modes:

Since the default mode was MODE_THREADLOCAL, in my server setup where no specific configuration was applied, SecurityContext was only returned in the main thread (http-nio-80-exec-3), while null was returned in the other child threads (ForkJoinPool.commonPool-worker-1~7).

Conclusion

Because SecurityContextHolder’s default setting is to share SecurityContext information only within the local thread, it is generally cleaner and potentially more performant to retrieve the SecurityContext in the main thread and pass that value to child threads, rather than calling SecurityContextHolder directly within the child threads.

If you are using features like parallelStream or Async and need to use SecurityContextHolder within child threads, you should consider switching SecurityContextHolder’s sharing mode to MODE_INHERITABLETHREADLOCAL.



Spring Security: SecurityContextHolder's Thread Sharing Strategies
Author
Aaron
Posted on
Licensed Under
CC BY-NC-SA 4.0
CC BY-NC-SA 4.0
More in this category
Recent posts
The Erosion of Conversational Muscle and Communication Styles by LLM Filters
In an era where LLM tools, which filter out conversational impoliteness and deliver refined responses, have become commonplace, are we truly engaging in more thoughtful conversations? This article examines the phenomenon of conversational ability, which should be honed through countless failures in real-time communication, degenerating due to reliance on external tools. It further explores the potential societal anxieties and shifts in generational behavioral patterns that this trend may bring.
Optimal Timing and Strategy for Salary Negotiation with Senior Candidates
Salary negotiation is more than just an exchange of figures; it's a strategic dance of psychological timing. This analysis explores why engaging in a gradual negotiation process from the initial stages of recruitment, rather than waiting until after a final offer (when candidates tend to adopt a more calculative stance), proves more efficient for companies and fosters a more honest sharing of resources.
The Limits of the Rule of Law and Human Diversity
The belief that all human actions can be regulated by a single legal system may be an act of hubris. This article offers a sharp analysis of the paradox of the rule of law faced by humanity, which, having escaped the hierarchical controls of the Middle Ages, has now embraced infinite modern freedom. It further examines the deepening social coercion and the demonization of others that arise under the guise of diversity.
토스트 예시 메세지