Skip to content

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.

SecurityConfiguration.java
@Configuration
public 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.

application-dev.yml
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.web.FilterChainProxy: TRACE

With 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.

SecurityTestConfig.java
@TestConfiguration
public 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:

OrderControllerTest.java
@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:

DocumentController.java
@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:

OrderService.java
@Service
public 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:

OrderSecurity.java
@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:

DevHelperController.java
@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:

  1. Call /api/dev/token?username=testuser&roles=USER,ADMIN to get a valid JWT
  2. Use that token in my API client
  3. Call /api/dev/whoami to 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:

Project Structure
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.java

This 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-PatternWhy It’s BadBetter Alternative
permitAll() everywhereNo actual securityUse profiles for dev/prod
Disabled CSRF on web appsCSRF vulnerabilityEnable CSRF for session-based apps
Hardcoded credentials in codeSecurity riskUse environment variables
No security testsBugs in productionWrite security test cases
Skip authentication in testsFalse confidenceUse @WithMockUser

My Daily Development Checklist

I keep this checklist visible in my IDE:

  • Use correct profile (dev for development)
  • Check security logs for failed auth attempts
  • Test with different user roles
  • Verify tenant isolation (if multi-tenant)
  • Use /api/dev/whoami to 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:

SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public 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:

CustomAccessDeniedHandler.java
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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments