The Paradigm Shift: Securing Decoupled Enterprise Systems
When transitioning from a server-rendered monolith (such as Laravel with Blade or traditional Spring MVC with Thymeleaf) to a decoupled Spring Boot 3.4 REST API and React 19 Single Page Application (SPA), the security architecture fundamentally shifts.
In a traditional monolith:
- The browser stores a session cookie (
JSESSIONIDorlaravel_session). - The server maintains stateful session objects in memory or Redis.
- Every state mutation relies on CSRF tokens embedded within HTML forms.
In a high-concurrency, multi-client academic platform (open-sourced at mfarim/spring-elearning-react):
- Stateless Scalability: The application backend must run across multiple Docker containers behind reverse proxies without relying on sticky sessions or shared session state.
- Multi-Client Consistency: The same API serves web browsers, mobile apps (React Native / iOS / Android), and CBT lockdown browser environments uniformly.
- Defense-in-Depth: Coarse-grained URL path matchers (
requestMatchers("/api/v1/admin/**")) are insufficient. Academic institutions require granular, method-level authorization with fine-grained role evaluation at compile-time and runtime. - Brute-Force & Flood Resilience: Public authentication and high-stakes examination endpoints must be shielded against automated credential stuffing, distributed denial of service, and rapid network retries.
In this deep dive, we break down the production security architecture implemented in mfarim/spring-elearning-react, covering the JJWT 0.12 stateless token engine, declarative @PreAuthorize method-level RBAC, Bucket4j token bucket rate limiting, and a complete OWASP Top 10 mitigation matrix.
1. The Modern Stateless JWT Engine (JJWT 0.12)
Many Java applications still use deprecated JJWT APIs (Jwts.parser().setSigningKey(...)) that lack modern compile-time type safety and cryptographic enforcement. In this project, we implement the modern JJWT 0.12 fluent API.
Cryptographic Key Enforcement & Token Issuance
In backend/src/main/java/com/elearning/security/JwtProvider.java, the constructor enforces strict cryptographic standards upon application bootstrap. Weak keys are rejected immediately to prevent forged token vulnerabilities:
JAVA
// backend/src/main/java/com/elearning/security/JwtProvider.java
@Component
public class JwtProvider {
private final SecretKey secretKey;
private final long accessTokenExpirationMs;
private final long refreshTokenExpirationMs;
public JwtProvider(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.access-token-expiration-ms:900000}") long accessTokenExpirationMs,
@Value("${app.jwt.refresh-token-expiration-ms:604800000}") long refreshTokenExpirationMs
) {
if (secret == null || secret.trim().isEmpty()) {
throw new IllegalStateException("CRITICAL: app.jwt.secret is not configured! Please provide JWT_SECRET environment variable.");
}
byte[] keyBytes = Decoders.BASE64.decode(secret.trim());
// Cryptographic Enforcement: Must be at least 256 bits (32 bytes) for HMAC-SHA256
if (keyBytes.length < 32) {
throw new IllegalArgumentException("CRITICAL: JWT secret key must be at least 256 bits (32 bytes) long for HMAC-SHA256!");
}
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenExpirationMs = accessTokenExpirationMs; // 15 minutes default
this.refreshTokenExpirationMs = refreshTokenExpirationMs; // 7 days default
}
public String generateAccessToken(UserPrincipal userPrincipal) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenExpirationMs);
List<String> roles = userPrincipal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
var builder = Jwts.builder()
.subject(userPrincipal.getId().toString())
.claim("email", userPrincipal.getEmail())
.claim("name", userPrincipal.getName())
.claim("roles", roles)
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey);
// Append administrative impersonator audit claim if active
if (userPrincipal.getImpersonatorAdminId() != null) {
builder.claim("impersonatorAdminId", userPrincipal.getImpersonatorAdminId());
}
return builder.compact();
}
}
The Inspection Pipeline: JwtAuthenticationFilter
Every incoming HTTP request traverses JwtAuthenticationFilter, which extends Spring's OncePerRequestFilter:
JAVA
// backend/src/main/java/com/elearning/security/JwtAuthenticationFilter.java
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) {
Long userId = jwtProvider.getUserIdFromToken(token);
Long impersonatorAdminId = jwtProvider.getImpersonatorAdminIdFromToken(token);
// Load immutable user principal including current authority grants
UserDetails userDetails = userDetailsService.loadUserById(userId, impersonatorAdminId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// Establish security context for downstream thread execution
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
2. Method-Level Role-Based Access Control (RBAC) & SpEL
Many teams configure security exclusively at the HTTP routing level inside SecurityConfig.java:
JAVA
// Coarse-grained URL matching (Necessary, but NOT sufficient on its own)
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/teacher/**").hasAnyRole("ADMIN", "TEACHER")
.requestMatchers("/api/v1/student/**").hasAnyRole("ADMIN", "STUDENT")
.anyRequest().authenticated()
);
While URL matchers establish an outer boundary, relying solely on them has severe pitfalls:
- Endpoint Refactoring Risk: If a developer changes
/api/v1/teacher/examsto/api/v1/exams, an unintentional authorization bypass occurs if route patterns are omitted inSecurityConfig. - No Contextual Logic: URL matchers cannot evaluate method arguments (e.g., verifying that a teacher owns the specific exam ID being modified).
- No Domain-Driven Granularity: Actions like publishing a draft exam or updating a question item belong to the same base resource
/api/v1/exams/{id}, but may require distinct role permissions.
Enabling Method Security
In Spring Boot 3 / Spring Security 6, we enable declarative method security by adding @EnableMethodSecurity to SecurityConfig.java:
JAVA
// backend/src/main/java/com/elearning/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // Activates @PreAuthorize and @PostAuthorize
@RequiredArgsConstructor
public class SecurityConfig {
// ...
}
Authority Mapping in UserPrincipal
Spring Security expects authorities formatted with the ROLE_ prefix when evaluating hasRole(...) expressions. In UserPrincipal.java, user roles are mapped cleanly to SimpleGrantedAuthority:
JAVA
// backend/src/main/java/com/elearning/security/UserPrincipal.java
public static UserPrincipal create(User user, Long impersonatorAdminId) {
Collection<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName().name())) // e.g. "ROLE_ADMIN", "ROLE_TEACHER"
.collect(Collectors.toList());
return new UserPrincipal(
user.getId(),
user.getName(),
user.getEmail(),
user.getPassword(),
user.isActive(),
authorities,
impersonatorAdminId
);
}
Granular Controller Declarations with SpEL
Now, each controller method declares its exact authorization contract via Spring Expression Language (SpEL):
JAVA
// backend/src/main/java/com/elearning/modules/exam/controller/ExamController.java
@RestController
@RequestMapping("/api/v1/exams")
@RequiredArgsConstructor
public class ExamController {
private final ExamService examService;
// Both Students and Teachers can list accessible exams, but service filters by enrollment
@GetMapping
public ResponseEntity<ApiResponse<List<ExamResponse>>> getExams(
@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(ApiResponse.success(examService.getExamsForUser(principal), "Exams fetched"));
}
// Only Teachers and Admins can create new examinations
@PostMapping
@PreAuthorize("hasAnyRole('ADMIN', 'TEACHER')")
public ResponseEntity<ApiResponse<ExamResponse>> createExam(
@Valid @RequestBody ExamDto dto,
@AuthenticationPrincipal UserPrincipal principal) {
ExamResponse response = examService.createExam(dto, principal);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response, "Exam created"));
}
// Updating exam metadata is restricted to Teachers and Admins
@PutMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'TEACHER')")
public ResponseEntity<ApiResponse<ExamResponse>> updateExam(
@PathVariable Long id,
@Valid @RequestBody ExamDto dto,
@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(ApiResponse.success(examService.updateExam(id, dto, principal), "Exam updated"));
}
// Question bank manipulation restricted to faculty
@PostMapping("/{id}/questions")
@PreAuthorize("hasAnyRole('ADMIN', 'TEACHER')")
public ResponseEntity<ApiResponse<QuestionResponse>> addQuestion(
@PathVariable Long id,
@Valid @RequestBody QuestionDto dto,
@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.status(HttpStatus.CREATED).body(
ApiResponse.success(examService.addQuestion(id, dto, principal), "Question added")
);
}
}
Preventing Insecure Direct Object References (IDOR)
Method-level security handles the role check. However, an authorized teacher must not be allowed to delete another teacher's exam!
To prevent IDOR (OWASP A01), the service layer enforces resource ownership validation using the injected UserPrincipal:
JAVA
// backend/src/main/java/com/elearning/modules/exam/service/ExamService.java
@Transactional
public void deleteExam(Long id, UserPrincipal principal) {
Examination exam = examRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Examination not found with id: " + id));
// Admin can delete any exam; Teachers can only delete their own exams
boolean isAdmin = principal.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
boolean isOwner = exam.getTeacher().getId().equals(principal.getId());
if (!isAdmin && !isOwner) {
throw new SecurityException("Forbidden: You do not have permission to delete an examination created by another teacher.");
}
examRepository.delete(exam);
}
3. Defense Against Brute-Force & Flooding: Bucket4j Token Bucket
Authentication endpoints (/api/v1/auth/login) and CBT submission endpoints (/api/v1/exams/{id}/submit) are the most targeted attack surfaces. Without server-side rate limiting, attackers can launch credential stuffing dictionaries or flood exam grading queues.
In spring-elearning-react, we engineer a multi-tiered server-side rate limiting filter utilizing Bucket4j:
JAVA
// backend/src/main/java/com/elearning/security/RateLimitingFilter.java
@Component
@Slf4j
public class RateLimitingFilter extends OncePerRequestFilter {
private final Map<String, Bucket> authIpBuckets = new ConcurrentHashMap<>();
private final Map<String, Bucket> submitIpBuckets = new ConcurrentHashMap<>();
private final Map<String, Bucket> generalIpBuckets = new ConcurrentHashMap<>();
// Multi-Tier Rate Limit Capacities
private static final int AUTH_CAPACITY = 10; // 10 login/auth requests per minute per IP
private static final int SUBMIT_CAPACITY = 60; // 60 autosave/submit requests per minute per IP
private static final int GENERAL_CAPACITY = 120; // 120 general API requests per minute per IP
private Bucket createBucket(int capacity, Duration duration) {
Bandwidth limit = Bandwidth.classic(capacity, Refill.greedy(capacity, duration));
return Bucket.builder().addLimit(limit).build();
}
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
String path = request.getRequestURI();
// Skip rate limiting for static documentation, health probes, and preflight CORS
return "OPTIONS".equalsIgnoreCase(request.getMethod())
|| path.startsWith("/actuator")
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs")
|| path.startsWith("/ws");
}
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String clientIp = LoginRateLimiterService.getClientIp(request);
String requestUri = request.getRequestURI();
// Tier 1: Strictest limit on Auth endpoints (Login / Register)
if (requestUri.startsWith("/api/v1/auth/login") || requestUri.startsWith("/api/v1/auth/register")) {
Bucket authBucket = authIpBuckets.computeIfAbsent(
clientIp, k -> createBucket(AUTH_CAPACITY, Duration.ofMinutes(1))
);
if (!authBucket.tryConsume(1)) {
log.warn("Rate limit [AUTH] exceeded for IP {} on {}", clientIp, requestUri);
sendRateLimitResponse(response, "Too many authentication attempts. Please wait 1 minute before retrying.");
return;
}
}
// Tier 2: Anti-flood protection for exam autosave and submissions
else if (requestUri.contains("/exams/") && (requestUri.endsWith("/submit") || requestUri.endsWith("/answer"))) {
Bucket submitBucket = submitIpBuckets.computeIfAbsent(
clientIp, k -> createBucket(SUBMIT_CAPACITY, Duration.ofMinutes(1))
);
if (!submitBucket.tryConsume(1)) {
log.warn("Rate limit [EXAM SUBMIT] exceeded for IP {} on {}", clientIp, requestUri);
sendRateLimitResponse(response, "High exam submission velocity detected. Please wait a moment.");
return;
}
}
// Tier 3: General API tier for all other endpoints
else if (requestUri.startsWith("/api/v1")) {
Bucket generalBucket = generalIpBuckets.computeIfAbsent(
clientIp, k -> createBucket(GENERAL_CAPACITY, Duration.ofMinutes(1))
);
if (!generalBucket.tryConsume(1)) {
log.warn("Rate limit [GENERAL API] exceeded for IP {} on {}", clientIp, requestUri);
sendRateLimitResponse(response, "API request rate limit exceeded. Please throttle your requests.");
return;
}
}
filterChain.doFilter(request, response);
}
private void sendRateLimitResponse(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // HTTP 429
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setHeader("Retry-After", "60");
response.setHeader("X-RateLimit-Retry-After-Seconds", "60");
String jsonPayload = String.format("{\"success\":false,\"message\":\"%s\",\"data\":null}", message);
response.getWriter().write(jsonPayload);
response.getWriter().flush();
}
}
The Filter Chain Ordering Trap
A common pitfall in Spring Security is registering a filter as both a Spring @Component and within HttpSecurity.addFilterBefore(...). When this happens, the servlet container executes the filter twice per request!
In SecurityConfig.java, we solve this cleanly by:
- Disabling the default servlet registration via
FilterRegistrationBean. - Explicitly placing
RateLimitingFilterbeforeLogoutFilter.classso excessive requests are rejected before allocating database or crypto resources:
JAVA
// backend/src/main/java/com/elearning/config/SecurityConfig.java
http
.addFilterBefore(rateLimitingFilter, LogoutFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@Bean
public FilterRegistrationBean<RateLimitingFilter> rateLimitingFilterRegistration(RateLimitingFilter filter) {
FilterRegistrationBean<RateLimitingFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false); // Prevents duplicate execution by Servlet container
return registration;
}
4. The 1-Click Administrative Impersonation Engine
In high-stakes academic environments, school administrators frequently need to diagnose why a student's examination attempt is malfunctioning. In a session-based monolith, admins call Auth::loginUsingId(). In a stateless JWT architecture, how do you impersonate a user securely without compromising password hashes or session integrity?
In AuthController.java and AuthService.java, we implement an audit-safe Impersonation Engine:
JAVA
// backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java
@PostMapping("/admin/impersonate/{userId}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Start Impersonation", description = "Admin can impersonate a teacher or student user")
public ResponseEntity<ApiResponse<AuthResponse>> impersonate(
@PathVariable Long userId,
@AuthenticationPrincipal UserPrincipal adminPrincipal
) {
AuthResponse response = authService.impersonate(userId, adminPrincipal);
return ResponseEntity.ok(ApiResponse.ok(response, "Impersonation started successfully"));
}
@PostMapping("/admin/stop-impersonate")
@Operation(summary = "Stop Impersonation", description = "Revert impersonated session back to administrator")
public ResponseEntity<ApiResponse<AuthResponse>> stopImpersonate(
@AuthenticationPrincipal UserPrincipal currentPrincipal
) {
AuthResponse response = authService.stopImpersonate(currentPrincipal);
return ResponseEntity.ok(ApiResponse.ok(response, "Returned to admin session"));
}
How the Token Preserves Audit Integrity
When authService.impersonate(userId, adminPrincipal) runs:
- It validates that the target user exists and is active.
- It generates a valid access token whose
subjectis the target student's ID and whoserolesmatch the target student's permissions. - Crucially, it attaches an
impersonatorAdminIdclaim containing the administrator's ID. - When
stopImpersonateis called, the backend readscurrentPrincipal.getImpersonatorAdminId(), verifies the original admin account, and seamlessly issues a fresh admin token.
No passwords are ever exposed, and all mutations performed while impersonating carry the admin audit ID in server logs!
5. Enterprise OWASP Top 10 Security Mapping
Here is the complete architectural mapping showing how each vulnerability in the OWASP Top 10 (2021) standard is directly neutralized across the Java Spring Boot + React codebase:
| OWASP Vulnerability | Risk Scenario | Engineering Mitigation in spring-elearning-react |
|---|---|---|
| A01: Broken Access Control | Unauthorized student modifying grades, reading other students' exams (IDOR), or path traversal. | • Declarative @PreAuthorize("hasAnyRole(...)") on controllers.• Resource ownership verification in service layer. • Strict .startsWith(uploadDir) path traversal boundaries in FileStorageService. |
| A02: Cryptographic Failures | Weak password storage or compromised token signing keys. | • BCryptPasswordEncoder with strength factor 12.• Mandatory 256-bit (32-byte) key validation in JwtProvider for HMAC-SHA256.• HTTPS enforced at Host Nginx reverse proxy. |
| A03: Injection | SQL injection in search bars, question filters, or student rosters. | • 100% Parameterized queries via Spring Data JPA and Hibernate. • Zero raw SQL string concatenations. • Input type binding via strong DTOs. |
| A04: Insecure Design | Student manipulating local computer clock to gain exam time or bypassing submission rules. | • Server-authoritative timer logic calculated from database start timestamp (calculateRemainingSeconds).• Tab switch blur detection tracked server-side. |
| A05: Security Misconfiguration | Leaking database SQL dialect or stack traces to public API callers. | • GlobalExceptionHandler masks internal SQL exceptions with generic messages (CWE-209 mitigation).• Actuator endpoints restricted to /actuator/health and /actuator/info. |
| A06: Vulnerable Components | Known CVEs in outdated Spring Boot or transitive Maven libraries. | • Modern baseline on Java 21 LTS and Spring Boot 3.4.3. • Continuous dependency scanning with Dependabot and Maven security plugins. |
| A07: Identification Failures | Credential stuffing, brute-force password guessing, session hijacking. | • Multi-tier Bucket4j RateLimitingFilter (10 req/min/IP).• Refresh token rotation engine. • Short-lived 15-minute access token window. |
| A08: Software & Data Integrity | Deserialization of untrusted payloads or invalid entity schemas. | • Strict input validation via jakarta.validation (@Valid, @NotBlank, @Size, @Email).• DTO immutability via Java records and Lombok builders. |
| A09: Security Logging & Monitoring | Silent failures during security breaches or lack of traceability. | • SLF4J structured logging for all security exceptions. • Impersonator audit trail embedded in JWT claims. • Actuator health probes for container orchestrator readiness. |
| A10: Server-Side Request Forgery (SSRF) | Attacker coercing the server to fetch malicious remote URLs. | • Static file storage sandbox with isolated internal storage locations. • No unvalidated external URL fetching permitted in upload endpoints. |
Conclusion & What's Next
Enterprise security is not achieved through superficial plugins or haphazard code snippets—it demands a cohesive, layered architecture.
By combining the JJWT 0.12 modern builder engine, declarative @PreAuthorize method security, Bucket4j token bucket rate limiting, and defense-in-depth OWASP Top 10 mitigation, the Spring E-Learning platform demonstrates how a modern Java 21 & React 19 application achieves banking-grade resilience while remaining responsive under high-concurrency academic workloads.
In Part 4 (Final), we conclude this series with the ultimate operational milestone: Single VPS Production Deployment: Docker Compose, Host Nginx Reverse Proxy & SSL Let's Encrypt.
- Explore Full Source Code: github.com/mfarim/spring-elearning-react
- Review Security Hardening PR #1: github.com/mfarim/spring-elearning-react/pull/1

