📚 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 environment —
CORS_ALLOWED_ORIGINSenv 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
- [ ]
@WithMockUserfor 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:
- The GUARD framework (Granular rules, User roles, Authentication, Request filtering, Defense-in-depth) guides security configuration
SecurityFilterChainwith method-based authorization maps cleanly to REST API semantics- Rule order matters — most specific first, catch-all
anyRequest()last - BCrypt strength 12 provides strong password hashing with reasonable performance
- Production fail-fast prevents deploying with default credentials
- CORS configuration is environment-driven — dev-friendly defaults, production requires explicit origins
TestSecurityConfigseparates test security from production security cleanly@WithMockUserenables 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. ☕