Spring Security Development Workflow: 7 Practical Tips for Productive Coding
I was testing my API endpoints the other day, and I kept getting 401 Unauthorized errors. I knew Spring Security was doing its job, but I couldn’t figure out which rule was blocking my requests. Was it the CSRF protection? The missing role? The wrong endpoint pattern?
After an hour of frustration, I realized something: I didn’t have a proper development workflow for Spring Security. I was treating security as an afterthought instead of building it into my daily coding process.
Here’s what I learned after fixing my workflow.
The Core Problem
Spring Security is powerful, but that power comes with complexity. During development, you face several friction points:
- 401/403 errors without clear explanations
- Difficulty testing different user roles
- CSRF token issues with API testing tools
- Security filters interfering with debugging
- Uncertainty about what to disable vs. keep enabled
The fear is real. I’ve seen developers say things like “I’m always afraid of Spring Security” because they don’t have a strategy.
Tip 1: Use Profile-Based Security Configurations
My first mistake was having one monolithic security configuration that tried to work for all environments. This led to constant commenting and uncommenting of code.
@Configurationpublic class SecurityConfiguration {
@Bean @Profile({"dev", "test"}) public SecurityFilterChain devSecurity(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .csrf(csrf -> csrf.disable()) .headers(headers -> headers.frameOptions(f -> f.disable()));
return http.build(); }
@Bean @Profile("prod") public SecurityFilterChain prodSecurity(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(csrf -> csrf.disable()); // For JWT-based APIs
return http.build(); }}Now I run with spring.profiles.active=dev during development, and the security rules are relaxed. In production, the strict rules apply.
Why this works: You don’t have to choose between security and developer experience. You get both, just in different contexts.
Tip 2: Enable Security Debug Logging
I wasted so much time guessing why authentication failed. Then I discovered Spring Security has detailed debug logging.
logging: level: org.springframework.security: DEBUG org.springframework.security.web.FilterChainProxy: TRACEWith this enabled, I can see exactly which filter is rejecting my request and why. The logs show the full filter chain execution, making debugging much faster.
The trial-and-error: I initially set logging to DEBUG but still couldn’t see enough detail. TRACE level on FilterChainProxy was the key - it shows every filter decision.
Tip 3: Create a Security Testing Utility Class
Testing with different user roles was painful. I kept creating users in my database, setting passwords, managing tokens. Then I realized I could use an in-memory user store just for tests.
@TestConfigurationpublic class SecurityTestConfig {
public static final String TEST_USER = "testuser"; public static final String TEST_ADMIN = "testadmin"; public static final String TEST_PASSWORD = "password";
@Bean @Primary public UserDetailsService testUserDetailsService() { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser(User.withUsername(TEST_USER) .password("{noop}" + TEST_PASSWORD) .roles("USER") .build()); manager.createUser(User.withUsername(TEST_ADMIN) .password("{noop}" + TEST_PASSWORD) .roles("ADMIN", "USER") .build()); return manager; }}Now I can write tests without touching my real database:
@SpringBootTest@Import(SecurityTestConfig.class)class OrderControllerTest {
@Autowired private MockMvc mockMvc;
@Test @WithMockUser(username = "testuser", roles = {"USER"}) void userCanViewOwnOrders() throws Exception { mockMvc.perform(get("/api/orders")) .andExpect(status().isOk()); }
@Test @WithMockUser(username = "testadmin", roles = {"ADMIN"}) void adminCanViewAllOrders() throws Exception { mockMvc.perform(get("/api/orders/all")) .andExpect(status().isOk()); }}The insight: @WithMockUser is your friend. It creates a mock authentication without requiring any actual login flow.
Tip 4: Document Role Requirements on Endpoints
I inherited a codebase where endpoint security rules were scattered across multiple configuration classes. Finding out what role was needed for an endpoint felt like archaeology.
Now I document security requirements directly in the controller:
@RestController@RequestMapping("/api/documents")@Tag(name = "Documents", description = "Document management endpoints")public class DocumentController {
/** * Get document by ID. * Required role: USER * Tenant-scoped: Yes */ @GetMapping("/{id}") @PreAuthorize("hasRole('USER')") public DocumentDto getDocument(@PathVariable Long id) { return documentService.findById(id); }
/** * Delete document. * Required role: ADMIN or DOCUMENT_MANAGER * Tenant-scoped: Yes */ @DeleteMapping("/{id}") @PreAuthorize("hasAnyRole('ADMIN', 'DOCUMENT_MANAGER')") public void deleteDocument(@PathVariable Long id) { documentService.delete(id); }}This documentation appears in Swagger/OpenAPI too, so API consumers know what permissions they need.
Tip 5: Use Method Security for Fine-Grained Control
Initially, I put all my security rules in the SecurityFilterChain. That works for URL-based rules, but it falls apart when you need business logic in your authorization.
Here’s the pattern I now use:
@Servicepublic class OrderService {
// Read: USER role + same tenant @PreAuthorize("hasRole('USER') and @orderSecurity.canAccess(#orderId)") public Order getOrder(Long orderId) { return orderRepository.findById(orderId); }
// Write: MANAGER role + same tenant @PreAuthorize("hasRole('MANAGER') and @orderSecurity.canAccess(#orderId)") public Order updateOrder(Long orderId, OrderDto dto) { // ... }
// Admin: full access @PreAuthorize("hasRole('ADMIN')") public List<Order> getAllOrders() { return orderRepository.findAll(); }}The @orderSecurity bean handles tenant-level checks:
@Component("orderSecurity")public class OrderSecurity {
@Autowired private OrderRepository orderRepository;
@Autowired private TenantContext tenantContext;
public boolean canAccess(Long orderId) { Order order = orderRepository.findById(orderId); return order != null && order.getTenantId().equals(tenantContext.getCurrentTenantId()); }}Why this matters: URL-based security is coarse. Method security lets you enforce business rules like “users can only see their own orders” without polluting your service layer with permission checks.
Tip 6: Create Development Helper Endpoints
The biggest pain point was debugging authentication state. I’d add print statements, check database records, and still be confused about what Spring Security was seeing.
Then I built a helper endpoint that shows me exactly what’s happening:
@RestController@RequestMapping("/api/dev")@Profile("dev") // Only available in dev profile!public class DevHelperController {
@Autowired private JwtService jwtService;
@GetMapping("/whoami") public Map<String, Object> whoami(Authentication auth) { if (auth == null) { return Map.of("status", "unauthenticated"); } return Map.of( "username", auth.getName(), "authorities", auth.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList(), "details", auth.getDetails() ); }
@PostMapping("/token") public Map<String, String> createTestToken( @RequestParam String username, @RequestParam(defaultValue = "USER") String[] roles) {
String token = jwtService.generateToken(username, Arrays.asList(roles)); return Map.of( "token", token, "type", "Bearer", "usage", "Bearer " + token ); }}Now when I’m testing, I can:
- Call
/api/dev/token?username=testuser&roles=USER,ADMINto get a valid JWT - Use that token in my API client
- Call
/api/dev/whoamito verify what Spring Security sees
Critical note: The @Profile("dev") annotation ensures this endpoint never makes it to production. I also added a check in my CI pipeline to fail if any @Profile("dev") endpoints are found in production builds.
Tip 7: Organize Security Code Like Any Other Code
Security code deserves the same architectural care as the rest of your application. I used to throw everything into one big SecurityConfig.java, but that became unmaintainable.
Here’s the structure I use now:
src/main/java/com/example/├── security/│ ├── config/│ │ ├── SecurityConfig.java│ │ └── JwtConfig.java│ ├── filter/│ │ └── JwtAuthenticationFilter.java│ ├── service/│ │ ├── AuthService.java│ │ └── JwtService.java│ ├── domain/│ │ ├── UserPrincipal.java│ │ └── Permission.java│ └── handler/│ ├── JwtAuthenticationEntryPoint.java│ └── CustomAccessDeniedHandler.javaThis separation makes it easy to find what I need and keeps security concerns isolated.
Common Anti-Patterns I’ve Seen (and committed)
After working with multiple teams, I’ve noticed these patterns consistently cause problems:
| Anti-Pattern | Why It’s Bad | Better Alternative |
|---|---|---|
permitAll() everywhere | No actual security | Use profiles for dev/prod |
| Disabled CSRF on web apps | CSRF vulnerability | Enable CSRF for session-based apps |
| Hardcoded credentials in code | Security risk | Use environment variables |
| No security tests | Bugs in production | Write security test cases |
| Skip authentication in tests | False confidence | Use @WithMockUser |
My Daily Development Checklist
I keep this checklist visible in my IDE:
- Use correct profile (
devfor development) - Check security logs for failed auth attempts
- Test with different user roles
- Verify tenant isolation (if multi-tenant)
- Use
/api/dev/whoamito debug current auth state - Generate test tokens via helper endpoint
- Run security tests before committing
A Complete Example
Here’s how my production security config looks now:
@Configuration@EnableWebSecurity@EnableMethodSecuritypublic class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/public/**").permitAll() .requestMatchers("/api/dev/**").hasRole("DEV") // Extra protection .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated() ) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(e -> e .authenticationEntryPoint(new JwtAuthenticationEntryPoint()) .accessDeniedHandler(new CustomAccessDeniedHandler()) );
return http.build(); }
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }}And the custom access denied handler for better debugging:
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) { Authentication auth = SecurityContextHolder.getContext().getAuthentication();
log.warn("Access denied for user '{}' on path '{}'. Required authorities missing.", auth != null ? auth.getName() : "anonymous", request.getRequestURI() );
response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write( "{\"error\":\"Access Denied\",\"message\":\"" + ex.getMessage() + "\"}" ); }}What I Learned
The key insight is that Spring Security isn’t inherently difficult - it just needs a proper development workflow. By building tools that make security transparent during development (debug logging, helper endpoints, test utilities), you can work efficiently without sacrificing production security.
I used to fear Spring Security configuration. Now I view it as just another piece of code that needs proper organization, testing, and debugging tools. The framework is doing its job; I just needed to understand how to work with it, not against it.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Security Official Documentation
- 👨💻 Spring Security Deep Dive - Conference Talk
- 👨💻 Spring Security Testing Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments