CheckUS JWT Design and ThreadLocal Security

Series Navigation


Previously

Part 2 examined CheckUS’s 4-Tier architecture implementation. We understood how the 4-layer security check works from frontend Axios Interceptor to backend AOP.

This article shares the security and performance issues encountered when applying this architecture in production, and how we solved them.


JWT Token Design: Storing Multiple Campus Roles

Requirements

CheckUS users can have multiple roles across multiple campuses.

[Teacher A]
  ├─ Gangnam Study Center: TEACHER
  ├─ Bundang Math Academy: TEACHER
  └─ Daechi English Academy: ADMIN

[Student B]
  ├─ Gangnam Study Center: STUDENT
  └─ Bundang Math Academy: STUDENT

How do we store this information in JWT tokens?

Attempt 1: Simple Arrays (❌ Cannot Distinguish Roles)

{
  "userId": 100,
  "username": "teacher_a",
  "campusIds": [1, 2, 3],
  "roles": ["TEACHER", "ADMIN"]
}

Problems

  • ❌ Cannot tell which role for which campus
  • ❌ TEACHER at campus 1, ADMIN at campus 3, but no distinction possible

Attempt 2: Nested Object Array (✅ Adopted)

{
  "userId": 100,
  "username": "teacher_a",
  "roles": [
    { "campusId": 1, "role": "TEACHER" },
    { "campusId": 2, "role": "TEACHER" },
    { "campusId": 3, "role": "ADMIN" }
  ]
}

Benefits

  • ✅ Clear distinction of roles per campus
  • ✅ Permission validation possible with roles.find(r => r.campusId === 1 && r.role === 'ADMIN')

JWT Claims Class

// Backend Implementation

@Getter
@Builder
public class JwtClaims {
    private Long userId;
    private String username;
    private List<CampusRole> roles;

    @Getter
    @AllArgsConstructor
    public static class CampusRole {
        private Long campusId;
        private String role;  // STUDENT, TEACHER, GUARDIAN, ADMIN
    }
}

JWT Generation and Parsing

// Backend Implementation

@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    public String generateToken(Long userId, String username, List<CampusRole> roles) {
        // Serialize roles to JSON string
        String rolesJson = new ObjectMapper().writeValueAsString(roles);

        return Jwts.builder()
            .setSubject(username)
            .claim("userId", userId)
            .claim("roles", rolesJson)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1 hour
            .signWith(SignatureAlgorithm.HS512, secretKey)
            .compact();
    }

    public Set<Long> getCampusIds(String token) {
        Claims claims = parseClaims(token);
        String rolesJson = claims.get("roles", String.class);

        List<CampusRole> roles = new ObjectMapper().readValue(
            rolesJson,
            new TypeReference<List<CampusRole>>() {}
        );

        return roles.stream()
            .map(CampusRole::getCampusId)
            .collect(Collectors.toSet());
    }

    public boolean hasRole(String token, Long campusId, String role) {
        Claims claims = parseClaims(token);
        String rolesJson = claims.get("roles", String.class);

        List<CampusRole> roles = new ObjectMapper().readValue(
            rolesJson,
            new TypeReference<List<CampusRole>>() {}
        );

        return roles.stream()
            .anyMatch(r -> r.getCampusId().equals(campusId) && r.getRole().equals(role));
    }
}

ThreadLocal Performance and Safety

What is ThreadLocal?

ThreadLocal provides independent variable storage for each thread.

Request 1 (Thread 1) → ThreadLocal: campusId = 1
Request 2 (Thread 2) → ThreadLocal: campusId = 2
Request 3 (Thread 1) → ThreadLocal: campusId = 3 (after Request 1 completes)

Risk 1: Memory Leaks

Spring Boot uses a Thread Pool. Since threads are reused:

// Request 1 (Thread A)
CampusContextHolder.setCampusIds(Set.of(1L));
// ... processing ...
// What if we don't clear()?

// Request 2 (Thread A reused)
CampusContextHolder.getCampusIds();  // 💥 Returns previous request's 1L!

Solution: Automatic Cleanup in Interceptor

@Override
public void afterCompletion(HttpServletRequest request,
                           HttpServletResponse response,
                           Object handler,
                           Exception ex) {
    CampusContextHolder.clear();  // ✅ Must clean up
}

Risk 2: ThreadLocal Loss in Async Operations

@CampusFiltered
public void sendNotifications() {
    Long campusId = CampusContextHolder.getSingleCampusId();  // ✅ 1L

    // Async task
    CompletableFuture.runAsync(() -> {
        Long asyncCampusId = CampusContextHolder.getSingleCampusId();
        // 💥 null! Different thread, cannot access ThreadLocal
    });
}

Solution: ThreadLocal Propagation with TaskDecorator

// Backend Implementation

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setTaskDecorator(new CampusContextTaskDecorator());  // ✅
        executor.initialize();
        return executor;
    }
}

// Decorator: Copies parent thread's ThreadLocal to child
public class CampusContextTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Set<Long> campusIds = CampusContextHolder.getCampusIds();  // Copy from parent thread

        return () -> {
            try {
                CampusContextHolder.setCampusIds(campusIds);  // Set in child thread
                runnable.run();
            } finally {
                CampusContextHolder.clear();  // Clean up after task
            }
        };
    }
}

ThreadLocal Performance Measurement

// Load test results with 10,000 requests

- Without ThreadLocal: Average 15ms/request
- With ThreadLocal: Average 15.2ms/request

Performance impact: About 1.3% (negligible)

Conclusion

  • ✅ ThreadLocal performance overhead is almost zero
  • ✅ Safe to use if memory leaks are prevented

Integration Testing: Verifying Campus Isolation

Testing Strategy

Unit tests alone are insufficient. Must verify End-to-End from actual HTTP requests to database queries.

Test Scenarios

1. Create 5 students for Gangnam Study Center (campusId=1)
2. Create 3 students for Bundang Math Academy (campusId=2)
3. API call with X-Campus-Id: 1 → Verify only 5 students returned
4. API call with X-Campus-Id: 2 → Verify only 3 students returned
5. Unauthorized campus request (campusId=999) → Verify 403 error

Integration Test Code

// Backend Implementation

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class StudentControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private StudentRepository studentRepository;

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @BeforeEach
    void setup() {
        // Create test data
        createStudent("Student A", 1L);  // Gangnam Study Center
        createStudent("Student B", 1L);
        createStudent("Student C", 2L);  // Bundang Math Academy
    }

    @Test
    void should_only_return_gangnam_students() throws Exception {
        // Generate JWT token (has campusId 1, 2 permissions)
        String token = jwtTokenProvider.generateToken(
            100L,
            "teacher",
            List.of(
                new CampusRole(1L, "TEACHER"),
                new CampusRole(2L, "TEACHER")
            )
        );

        // API call
        MvcResult result = mockMvc.perform(
            get("/students")
                .header("Authorization", "Bearer " + token)
                .header("X-Campus-Id", "1")  // Request Gangnam Study Center
        )
        .andExpect(status().isOk())
        .andReturn();

        // Verify response
        List<StudentDto> students = parseResponse(result);

        assertThat(students).hasSize(2);  // Only 2 Gangnam students
        assertThat(students).extracting("name")
            .containsExactlyInAnyOrder("Student A", "Student B");  // No Student C
    }

    @Test
    void should_return_403_for_unauthorized_campus() throws Exception {
        String token = jwtTokenProvider.generateToken(
            100L,
            "teacher",
            List.of(new CampusRole(1L, "TEACHER"))  // Only campusId 1 permission
        );

        mockMvc.perform(
            get("/students")
                .header("Authorization", "Bearer " + token)
                .header("X-Campus-Id", "999")  // Unauthorized campus
        )
        .andExpect(status().isForbidden())
        .andExpect(jsonPath("$.errorCode").value("CAMPUS_ACCESS_DENIED"));
    }

    @Test
    void should_return_400_when_x_campus_id_missing() throws Exception {
        String token = jwtTokenProvider.generateToken(100L, "teacher", List.of());

        mockMvc.perform(
            get("/students")
                .header("Authorization", "Bearer " + token)
                // X-Campus-Id header missing
        )
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errorCode").value("CAMPUS_ID_REQUIRED"));
    }
}

Test Execution Results

✅ should_only_return_gangnam_students (120ms)
✅ should_return_403_for_unauthorized_campus (85ms)
✅ should_return_400_when_x_campus_id_missing (92ms)

Total: 3 tests, 3 passed, 0 failed

Monitoring: Detecting Missing Campus Filters

Problem Recognition

What if developers accidentally write methods without @CampusFiltered?

// ❌ Missing @CampusFiltered
public List<Student> getDangerousMethod() {
    return studentRepository.findAll();  // Exposes all data
}

Covering all endpoints with integration tests is difficult. Real-time monitoring is needed.

Solution 1: AOP Logging

@Aspect
@Component
public class CampusFilterAspect {

    @Before("@annotation(CampusFiltered)")
    public void checkCampusContext(JoinPoint joinPoint) {
        Set<Long> campusIds = CampusContextHolder.getCampusIds();

        // ✅ Always log
        log.info("CampusFiltered: method={}, campusIds={}",
                 joinPoint.getSignature().toShortString(),
                 campusIds);

        if (campusIds == null || campusIds.isEmpty()) {
            throw new BusinessException("CAMPUS_CONTEXT_EMPTY");
        }
    }
}

Solution 2: Sentry Integration

@Aspect
@Component
public class CampusFilterAspect {

    @Before("@annotation(CampusFiltered)")
    public void checkCampusContext(JoinPoint joinPoint) {
        Set<Long> campusIds = CampusContextHolder.getCampusIds();

        if (campusIds == null || campusIds.isEmpty()) {
            // Send error to Sentry
            Sentry.captureException(new BusinessException(
                "CAMPUS_CONTEXT_EMPTY",
                String.format("Method: %s", joinPoint.getSignature())
            ));

            throw new BusinessException("CAMPUS_CONTEXT_EMPTY");
        }
    }
}

Solution 3: Enforce Integration Test Coverage

// Backend Implementation

test {
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test

    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, exclude: [
                '**/dto/**',
                '**/config/**'
            ])
        }))
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.80  // Enforce 80% coverage
            }
        }
    }
}

check.dependsOn jacocoTestCoverageVerification

Real-World Edge Cases

Case 1: Soft Delete and Campus Filtering

// students table
id | campus_id | name      | deleted_at
---+-----------+-----------+------------
1  | 1         | Student A | NULL
2  | 1         | Student B | 2025-01-01  (deleted)
3  | 2         | Student C | NULL

Question: Should campus filtering apply to soft deleted data too?

// ❌ Deleted data visible to other campuses
@Query("SELECT s FROM Student s WHERE s.deletedAt IS NOT NULL")
List<Student> findDeletedStudents();

// ✅ Deleted data also campus filtered
@CampusFiltered
public List<Student> getDeletedStudents() {
    Long campusId = CampusContextHolder.getSingleCampusId();
    return studentRepository.findByCampusIdAndDeletedAtIsNotNull(campusId);
}

Principle: All queries apply campus filtering regardless of deletion status

Case 2: Statistical Queries (Aggregating Multiple Campuses)

// Requirement: Aggregate student count across all campuses
public Map<Long, Long> getStudentCountPerCampus() {
    // ❌ Using @CampusFiltered queries only one campus
    // ✅ Explicitly indicate "querying all"

    return studentRepository.findAll().stream()
        .collect(Collectors.groupingBy(
            Student::getCampusId,
            Collectors.counting()
        ));
}

Solution: Don’t use @CampusFiltered + Include AllCampuses in method name

/**
 * ⚠️ Queries all campus data (admin only)
 * Campus filtering not applied
 */
@PreAuthorize("hasRole('SUPER_ADMIN')")
public Map<Long, Long> getStudentCountForAllCampuses() {
    return studentRepository.countGroupByCampusId();
}

Case 3: Cross-Campus Data Transfer

// Requirement: Transfer student from Gangnam Study Center(1) → Bundang Math Academy(2)
@Transactional
public void transferStudent(Long studentId, Long targetCampusId) {
    // 1. Query student from current campus
    Long currentCampusId = CampusContextHolder.getSingleCampusId();
    Student student = studentRepository.findByIdAndCampusId(studentId, currentCampusId)
        .orElseThrow(() -> new BusinessException("STUDENT_NOT_FOUND"));

    // 2. Validate targetCampusId permission
    if (!hasAccessToCampus(targetCampusId)) {
        throw new BusinessException("CAMPUS_ACCESS_DENIED");
    }

    // 3. Change campusId
    student.setCampusId(targetCampusId);
    studentRepository.save(student);
}

Note: ThreadLocal only holds campus set at request start, so separate permission validation needed when changing to another campus


Performance Optimization

1. Index Design

-- ✅ Composite index: campus_id + other conditions
CREATE INDEX idx_students_campus_grade ON students(campus_id, grade);
CREATE INDEX idx_study_times_campus_student ON study_times(campus_id, student_id);

-- ❌ Performance degradation with only single index
CREATE INDEX idx_students_grade ON students(grade);  -- No campus_id!

Query Performance Comparison

-- Using composite index (✅)
SELECT * FROM students
WHERE campus_id = 1 AND grade = 3;
-- Execution time: 2ms (index scan)

-- Using single index (❌)
SELECT * FROM students
WHERE grade = 3;  -- Missing campus_id filtering
-- Execution time: 150ms (full table scan)

2. Solving N+1 Problem

// ❌ N+1 queries occur
@CampusFiltered
public List<StudentWithClassDto> getStudentsWithClass() {
    Long campusId = CampusContextHolder.getSingleCampusId();
    List<Student> students = studentRepository.findByCampusId(campusId);

    return students.stream()
        .map(s -> new StudentWithClassDto(
            s,
            classRepository.findById(s.getClassId()).orElse(null)  // 💥 N queries
        ))
        .collect(Collectors.toList());
}

// ✅ Using Fetch Join
@Query("""
    SELECT s FROM Student s
    LEFT JOIN FETCH s.classEntity
    WHERE s.campusId = :campusId
""")
List<Student> findByCampusIdWithClass(@Param("campusId") Long campusId);

3. Caching Strategy

// ThreadLocal lookup is very fast (<1μs), caching unnecessary
// Instead cache DB query results

@Cacheable(value = "students", key = "#campusId")
public List<Student> getStudentsByCampus(Long campusId) {
    return studentRepository.findByCampusId(campusId);
}

// ⚠️ Note: Must separate cache keys per campus

Security Checklist

Required Checks During Development

  • Add @CampusFiltered annotation to all Service methods
  • Retrieve campusId from ThreadLocal and call Repository methods
  • Include WHERE campus_id = :campusId in Native Queries
  • Verify campus isolation with integration tests
  • Check Frontend Request DTOs have no campusId field via ESLint
  • Propagate ThreadLocal with TaskDecorator for async operations
  • Clean up ThreadLocal in Interceptor afterCompletion

Code Review Checklist

  • Are there Service methods without @CampusFiltered?
  • Are there places directly calling findAll() from Repository?
  • Do statistical queries access all campus data? (permission check needed)
  • Do Soft Delete queries apply campus filtering?

Next Episode Preview

Part 3 explored security and performance optimization strategies including JWT token design, ThreadLocal safety, integration testing, monitoring, and real edge cases.

Part 4: Comparing Implementation Methods will cover:

  • 🔍 PostgreSQL Native RLS vs CheckUS AOP
  • ⚡ Can Hibernate Global Filter achieve complete automation?
  • 🚀 How much faster with Redis caching?
  • 🎯 Pros and cons of AspectJ Load-Time Weaving
  • 📊 Comparative analysis of 4 real cases

We’ll objectively compare CheckUS’s approach with other industry implementation methods.

👉 Continue to Part 4: Comparing 5 Row-Level Security Implementations and Selection Guide


CheckUS Architecture Series

  • Part 1: One Account, Multiple Schools, Multiple Roles
  • Part 2: 4-Tier Security to Prevent Data Leaks in Multi-Tenancy
  • Part 3: Multi-Campus, Multi-Role JWT Design and ThreadLocal Safety ← Current
  • Part 4: Comparing 5 Row-Level Security Implementations and Selection Guide
  • Part 5: Legacy System Multi-Tenancy Migration