🔒 Guarding the Gates: Security Fundamentals for Microservices

Security is either invisible and effective or visible and broken. This article covers the GUARD framework for Spring Security — SecurityFilterChain configuration, role-based access control, CORS policies, BCrypt password encoding, and a test security setup that doesn't weaken production.

Spring Security fundamentals — RBAC, CORS, and SecurityFilterChain configuration

📚 Series Navigation:
Previous: Part 6 - Cache Me If You Can
👉 You are here: Part 7 - Guarding the Gates
Next: Part 8 - Fail Gracefully →


📋 Introduction

Security is the one thing nobody wants to think about until it's too late. You're building features, shipping code, making progress — and then someone discovers that your actuator endpoints are publicly accessible, your admin endpoints accept anonymous requests, and your API lets anyone delete every location in the database.

It's not that developers don't care about security. It's that security configuration in Spring Boot is genuinely confusing. The SecurityFilterChain API has evolved significantly across versions, tutorials are often outdated, and the difference between authenticated() and hasRole("USER") isn't always obvious until something goes wrong in production.

In this article, we'll demystify the Weather Microservice's security configuration. We'll see how it implements role-based access control, CORS policies, password encoding, and — critically — how it makes security testable without weakening it. ☕


🔒 The GUARD Framework: Five Pillars of Microservice Security

Meet GUARD — five principles for securing microservices:

Letter Principle What It Means
G Granular Rules Access rules per HTTP method, not blanket allow/deny
U User Roles Clear role hierarchy with least-privilege defaults
A Authentication HTTP Basic auth with BCrypt password hashing
R Request Filtering CORS, CSRF, and header policies configured explicitly
D Defense-in-depth Production fail-fast on missing credentials

🔐 The Security Filter Chain

The heart of Spring Security is the SecurityFilterChain. Here's the Weather Microservice's complete configuration:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http.authorizeHttpRequests(auth -> auth
            // Actuator endpoints - require admin role
            .requestMatchers(EndpointRequest.toAnyEndpoint())
            .hasRole("ACTUATOR_ADMIN")

            // H2 Console - only in dev profile with authentication
            .requestMatchers("/h2-console/**")
            .hasRole("ADMIN")

            // API Documentation - public access
            .requestMatchers("/swagger-ui/**", "/swagger-ui.html",
                "/v3/api-docs/**", "/api-docs/**")
            .permitAll()

            // DELETE operations - admin only
            .requestMatchers(HttpMethod.DELETE, "/api/**")
            .hasRole("ADMIN")

            // POST/PUT operations - authenticated users
            .requestMatchers(HttpMethod.POST, "/api/**")
            .hasRole("USER")
            .requestMatchers(HttpMethod.PUT, "/api/**")
            .hasRole("USER")

            // GET operations - public read access
            .requestMatchers(HttpMethod.GET, "/api/**")
            .permitAll()

            // All other requests require authentication
            .anyRequest()
            .authenticated())
        .httpBasic(Customizer.withDefaults())
        .csrf(AbstractHttpConfigurer::disable)
        .cors(Customizer.withDefaults())
        .headers(headers ->
            headers.frameOptions(frame -> frame.sameOrigin()))
        .build();
  }
}

Rule Order Matters

Spring Security evaluates rules top to bottom, first match wins. This ordering is deliberate:

1. Actuator → ACTUATOR_ADMIN   (most restricted, checked first)
2. H2 Console → ADMIN           (dev-only, restricted)
3. Swagger → permitAll           (documentation is public)
4. DELETE → ADMIN                (destructive operations need admin)
5. POST/PUT → USER              (write operations need auth)
6. GET → permitAll               (read operations are public)
7. anyRequest → authenticated    (catch-all safety net)

If the catch-all anyRequest().authenticated() were first, it would match everything and none of the specific rules would apply. Order is everything.

RBAC by HTTP Method

The Weather Microservice uses HTTP method-based access control — a clean pattern for REST APIs:

HTTP Method Required Role Rationale
GET None (public) Read operations are safe, no state changes
POST USER Creating resources requires authentication
PUT USER Updating resources requires authentication
DELETE ADMIN Destructive operations need elevated privileges
Actuator ACTUATOR_ADMIN Operations endpoints need separate admin role

This maps naturally to REST semantics. Anonymous users can browse weather data. Authenticated users can create and update locations. Only administrators can delete data or access operational endpoints.

💡 Pro Tip: Never give DELETE access to regular users in a REST API. It's irreversible and should always require elevated privileges.


👥 User Management and Password Security

BCrypt with Strength 12

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12);
}

BCrypt strength 12 means 2^12 = 4,096 hashing rounds. This takes approximately 200ms per hash on modern hardware — fast enough for login, slow enough to make brute-force attacks impractical.

BCrypt Strength Rounds Approx. Time Use Case
10 1,024 ~50ms Default, adequate
12 4,096 ~200ms Recommended for production
14 16,384 ~800ms High security, slower logins

Three User Roles

@Bean
public UserDetailsService userDetailsService() {
    UserDetails user = User.builder()
        .username(userUsername)
        .password(passwordEncoder().encode(userPassword))
        .roles("USER")
        .build();

    UserDetails admin = User.builder()
        .username(adminUsername)
        .password(passwordEncoder().encode(adminPassword))
        .roles("USER", "ADMIN")
        .build();

    UserDetails actuator = User.builder()
        .username(actuatorUsername)
        .password(passwordEncoder().encode(actuatorPassword))
        .roles("ACTUATOR_ADMIN")
        .build();

    return new InMemoryUserDetailsManager(user, admin, actuator);
}

Key design decisions:

  • Admin has both USER and ADMIN roles — Admins can do everything users can, plus delete
  • Actuator has its own separate role — Operations access is independent of application roles
  • Passwords are BCrypt-encoded — Even in-memory, passwords are never stored in plain text

Production Fail-Fast

boolean isProduction = "prod".equals(System.getenv("SPRING_PROFILES_ACTIVE"));
if (isProduction
    && (userPassword == null || adminPassword == null || actuatorPassword == null)) {
  throw new IllegalStateException(
      "Production environment requires APP_USER_PASSWORD, APP_ADMIN_PASSWORD, "
      + "and APP_ACTUATOR_PASSWORD environment variables. "
      + "Never use default credentials in production!");
}

// Fall back to default passwords in dev/test only
if (userPassword == null) userPassword = "user123";

This is defense-in-depth for credentials:

  • In development: Default passwords (user123, admin123) work out of the box
  • In production: Missing passwords cause a startup failure with a clear error message
  • No silent fallbacks: Production never silently uses default credentials

🔥 Critical Insight: This fail-fast approach is critical. Without it, a misconfigured production deployment could silently run with user123 as the admin password. The IllegalStateException at startup is infinitely better than a security breach at runtime.


🌐 CORS Configuration

Cross-Origin Resource Sharing controls which frontend applications can call your API:

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();

    String allowedOriginsEnv = System.getenv("CORS_ALLOWED_ORIGINS");
    if (allowedOriginsEnv != null && !allowedOriginsEnv.isBlank()) {
      configuration.setAllowedOrigins(List.of(allowedOriginsEnv.split(",")));
    } else {
      configuration.setAllowedOrigins(
          List.of("http://localhost:3000", "http://localhost:4200",
              "http://localhost:8080"));
    }

    configuration.setAllowedMethods(
        List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"));
    configuration.setAllowedHeaders(
        List.of("Authorization", "Content-Type", "Accept", "Origin",
            "X-Requested-With"));
    configuration.setExposedHeaders(
        List.of("Authorization", "Content-Disposition"));
    configuration.setAllowCredentials(true);
    configuration.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", configuration);
    source.registerCorsConfiguration("/actuator/**", configuration);
    return source;
}

Environment-Driven Origins

String allowedOriginsEnv = System.getenv("CORS_ALLOWED_ORIGINS");
if (allowedOriginsEnv != null && !allowedOriginsEnv.isBlank()) {
    configuration.setAllowedOrigins(List.of(allowedOriginsEnv.split(",")));
}

In production: CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com

In development: Falls back to common dev server ports (3000 for React, 4200 for Angular, 8080 for Spring).

CORS Settings Explained

Setting Value Purpose
allowedOrigins Environment-based Controls which domains can call the API
allowedMethods GET, POST, PUT, DELETE, OPTIONS, HEAD HTTP methods the frontend can use
allowedHeaders Authorization, Content-Type, etc. Headers the frontend can send
exposedHeaders Authorization, Content-Disposition Headers the frontend can read from responses
allowCredentials true Allows cookies and auth headers
maxAge 3600s Browser caches preflight results for 1 hour

Why CSRF Is Disabled

.csrf(AbstractHttpConfigurer::disable)

CSRF protection is designed for browser-based form submissions with cookies. REST APIs typically use token-based authentication (Bearer tokens) or HTTP Basic auth, where CSRF attacks aren't applicable. Enabling CSRF on a REST API would break clients that don't send CSRF tokens (every non-browser client).


🧪 Making Security Testable

One of the hardest parts of security is testing. The Weather Microservice uses a TestSecurityConfig that relaxes security for integration tests:

// TestSecurityConfig.java
@TestConfiguration
public class TestSecurityConfig {

  @Bean
  public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
        .httpBasic(Customizer.withDefaults())
        .csrf(AbstractHttpConfigurer::disable)
        .build();
  }

  @Bean
  public UserDetailsService testUserDetailsService() {
    // Simple users with "password" for all roles
    UserDetails user = User.builder()
        .username("user")
        .password(passwordEncoder().encode("password"))
        .roles("USER")
        .build();

    UserDetails admin = User.builder()
        .username("admin")
        .password(passwordEncoder().encode("password"))
        .roles("USER", "ADMIN")
        .build();

    return new InMemoryUserDetailsManager(user, admin);
  }
}

Test Configuration Strategy

Aspect Production Config Test Config
Authorization RBAC per HTTP method permitAll()
Passwords BCrypt strength 12, env vars Simple "password"
Users 3 users with specific roles 2 users with test roles
CSRF Disabled (REST API) Disabled (same)

Using @WithMockUser in Tests

// WeatherControllerIntegrationTest.java
@WebMvcTest(WeatherController.class)
@Import({TestSecurityConfig.class, GlobalExceptionHandler.class})
@ActiveProfiles("test")
class WeatherControllerIntegrationTest {

  @Test
  @WithMockUser(roles = "USER")
  void getCurrentWeather_shouldReturnWeatherData() throws Exception {
      // Test with USER role
  }

  @Test
  @WithMockUser(roles = "ADMIN")
  void deleteLocation_shouldSucceedWithAdminRole() throws Exception {
      // Test with ADMIN role
  }
}

The @WithMockUser annotation creates a fake authenticated user without needing actual credentials. This lets you test authorization rules without the overhead of real authentication.


🔍 Security Layers in Practice

Here's how a request flows through the security stack:

Incoming Request: DELETE /api/locations/42

1. CORS Filter
   +- Origin allowed? → Continue
   +- Origin blocked? → 403 Forbidden

2. Security Filter Chain
   +- Match: DELETE /api/** → requires ADMIN role
   +- Authentication: HTTP Basic header present?
   |   +- Yes → Decode, verify password against BCrypt hash
   |   +- No → 401 Unauthorized
   +- Authorization: User has ADMIN role?
       +- Yes → Continue to controller
       +- No → 403 Forbidden

3. Controller
   +- locationService.deleteLocation(42)

Each layer adds a check:

  • CORS — Is the caller's origin allowed?
  • Authentication — Who is the caller?
  • Authorization — Is the caller permitted to do this?

✅ Security Checklist

  • [ ] RBAC by HTTP method — GET is public, POST/PUT needs USER, DELETE needs ADMIN
  • [ ] Actuator endpoints protected — Separate ACTUATOR_ADMIN role
  • [ ] BCrypt strength 12 — Strong enough for production, fast enough for development
  • [ ] Production fail-fast — Missing passwords crash startup, never fall back to defaults
  • [ ] CORS configured per environmentCORS_ALLOWED_ORIGINS env var for production
  • [ ] CSRF disabled for REST API — Token-based auth doesn't need CSRF protection
  • [ ] Swagger/OpenAPI publicly accessible — Documentation shouldn't require auth
  • [ ] TestSecurityConfig for integration tests — Relaxed security without weakening production
  • [ ] @WithMockUser for role-based test scenarios
  • [ ] Rule order verified — Most specific rules first, catch-all last

🎓 Conclusion: Security Is a Feature, Not an Afterthought

Security done right is invisible to users and impenetrable to attackers. The key decisions:

  1. The GUARD framework (Granular rules, User roles, Authentication, Request filtering, Defense-in-depth) guides security configuration
  2. SecurityFilterChain with method-based authorization maps cleanly to REST API semantics
  3. Rule order matters — most specific first, catch-all anyRequest() last
  4. BCrypt strength 12 provides strong password hashing with reasonable performance
  5. Production fail-fast prevents deploying with default credentials
  6. CORS configuration is environment-driven — dev-friendly defaults, production requires explicit origins
  7. TestSecurityConfig separates test security from production security cleanly
  8. @WithMockUser enables testing authorization without real authentication

Security isn't something you bolt on after the feature is done. It's woven into the configuration from day one. The Weather Microservice treats security as a first-class concern — with clear rules, sensible defaults, and an absolute refusal to compromise in production.

Coming Next Week:
Part 8: Fail Gracefully - Error Handling, Validation, and the Art of Useful Errors 💥


📚 Series Progress

✅ Part 1: The Blueprint Before the Build
✅ Part 2: Spring Boot Alchemy
✅ Part 3: REST Assured
✅ Part 4: The Data Foundation
✅ Part 5: When the World Breaks
✅ Part 6: Cache Me If You Can
✅ Part 7: Guarding the Gates ← You just finished this!
⬜ Part 8: Fail Gracefully
⬜ Part 9: 10,000 Threads and a Dream
⬜ Part 10: Can You See Me Now?
⬜ Part 11: Trust, But Verify
⬜ Part 12: Ship It
⬜ Part 13: To Production and Beyond


Happy coding, and remember — the only thing worse than no security is security you think you have but don't.


Robert Marcel Saveanu

Robert Marcel Saveanu

Software engineer with 15 years in testing, architecture, and the art of surviving corporate dysfunction. Writing about code, quality, and the humans behind both.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Codyssey.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.