Intermediate · Secure

Production Implementation: Protected Task API

Ship the security slice end to end: stateless filter chain, bcrypt hashing, scoped authorities, and tests that pin every denied path.

Primary source: Spring Security 6.5 Reference Architecture

50 min

Lesson 5 of 5

1d streak

The Architectural Question

Security assembled from blog snippets fails closed unpredictably: wrong matcher order, sessions left on, and untested deny paths that regress the first time someone refactors.

What this guide proves

  • Declare one lambda-style SecurityFilterChain with ordered matchers
  • Hash passwords with a delegating bcrypt encoder
  • Prove 401 versus 403 paths in MockMvc

Defense-in-Depth Architecture: Multi-Layered API Security

This is the security integration capstone. Apply the earlier lessons on filter-chain ordering, authentication, CORS, CSRF, OAuth2, and method authorization to one production boundary. The goal is to verify that the controls compose correctly across layers, not to redefine each mechanism.

At the servlet boundary, DelegatingFilterProxy delegates to the Spring-managed FilterChainProxy. FilterChainProxy selects the matching SecurityFilterChain, which runs the authentication and authorization filters used by this configuration.

ProductionSecurityFilterChain.java
http
    .requiresChannel(channel -> channel.anyRequest().requiresSecure()) // Enforce HTTPS
    .headers(headers -> headers
        .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
        .httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).maxAgeInSeconds(31536000))
        .frameOptions(frame -> frame.deny())
    )
    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    // Safe only when the API accepts credentials exclusively in Authorization headers,
    // never from cookies or other browser-attached credentials.
    .csrf(AbstractHttpConfigurer::disable)
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/actuator/health/**").permitAll()
        .requestMatchers("/api/v1/public/**").permitAll()
        .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));

Stateless does not decide CSRF by itself

This capstone disables CSRF only for an explicit-header bearer-token API. If the same application accepts cookies, browser Basic authentication, or another ambient credential, keep CSRF protection enabled. The credential transport—not SessionCreationPolicy.STATELESS—determines the threat.

Active Recall Checkpoint

What security attack does frameOptions(frame -> frame.deny()) prevent?

Hint: What attack involves rendering your website inside a transparent iframe?

Show answer & explanation

Clickjacking attacks. Denying frames ensures that malicious external websites cannot embed your application inside an invisible iframe to trick users into clicking buttons.

Audit Logging & Tenancy Injection via SecurityContextHolder

Security auditing requires recording the authenticated user ID and tenant ID on every database modification. Spring Data JPA provides auditing integration via AuditorAware.

SpringSecurityAuditorAware.java
package com.example.security;

import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
public class SpringSecurityAuditorAware implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
            return Optional.of("system");
        }
        return Optional.ofNullable(auth.getName());
    }
}

Active Recall Checkpoint

Why should @EnableJpaAuditing be used with AuditorAware in enterprise applications?

Hint: How does it automate populating @CreatedBy and @LastModifiedBy columns?

Show answer & explanation

It automatically populates @CreatedBy and @LastModifiedBy audit fields on JPA entities using the authenticated principal from SecurityContextHolder, eliminating manual boilerplate and preventing audit tampering.

The Automated Security Test Matrix

A production security implementation is incomplete without an automated test matrix that proves every route enforces authentication, roles, and boundaries.

TaskSecurityIntegrationTest.java
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
class TaskSecurityIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void unauthenticatedRequest_Returns401() throws Exception {
        mockMvc.perform(get("/api/v1/tasks/123"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void userRole_CanAccessTasks() throws Exception {
        mockMvc.perform(get("/api/v1/tasks/123")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
            .andExpect(status().isOk());
    }

    @Test
    void userRole_CannotAccessAdminEndpoint() throws Exception {
        mockMvc.perform(delete("/api/v1/admin/tasks/123")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
            .andExpect(status().isForbidden());
    }
}

Active Recall Checkpoint

What is the semantic HTTP difference between 401 Unauthorized and 403 Forbidden in Spring Security?

Hint: Is the client authenticated, or do they lack permissions?

Show answer & explanation

401 Unauthorized indicates unauthenticated access (no credentials or invalid token supplied). 403 Forbidden indicates the client is authenticated, but lacks the necessary permissions or roles to execute the operation.

Deliberate Failure Lab: Detecting Silent Authorization Leaks

A critical vulnerability occurs when a new controller endpoint is added without updating the security filter chain or method security annotations. If .anyRequest().permitAll() was mistakenly used instead of .anyRequest().authenticated(), the new endpoint is silently open to the public internet.

The Strict Security Posture

Always adopt the principle of Default Deny: configure .anyRequest().denyAll() or .anyRequest().authenticated(). Any unmapped route must fail closed rather than failing open.

Active Recall Checkpoint

Why is "Default Deny" a safer baseline posture than "Default Allow"?

Hint: What happens when a developer adds a new internal API endpoint and forgets to configure authorization?

Show answer & explanation

Under "Default Deny", a forgotten configuration blocks access until explicitly permitted. Under "Default Allow", a forgotten configuration exposes sensitive internal endpoints to the entire internet.

Working Implementation

Code Walkthrough

Secure the task API with JWT resource-server validation and an authorization matrix no test can silently regress.

ProtectedTaskApiSecurityLab.java
package com.example.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;

@Configuration
@EnableMethodSecurity
public class ProtectedTaskApiSecurityLab {

    @Bean
    SecurityFilterChain productionFilterChain(HttpSecurity http) throws Exception {
        return http
                .requiresChannel(channel -> channel.anyRequest().requiresSecure()) // Enforce HTTPS
                .headers(headers -> headers
                        .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
                        .frameOptions(frame -> frame.deny())
                        .referrerPolicy(ref -> ref.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
                )
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(csrf -> csrf.disable())
                .cors(Customizer.withDefaults())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/actuator/health/**").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/v1/tasks/**").hasAnyRole("USER", "ADMIN")
                        .requestMatchers(HttpMethod.POST, "/api/v1/tasks/**").hasRole("ADMIN")
                        .anyRequest().denyAll()
                )
                .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
                .build();
    }
}
Defense-in-depth: enforces HTTPS channels, strict CSP, and denies framing to block clickjacking.
SessionCreationPolicy.STATELESS ensures Tomcat never allocates server-side session memory for API requests.
Strict request matcher matrix pairs with method security for multi-tiered authorization enforcement.

Core Spring Implementation

Protected Task API security slice

Ship JWT-backed API security: a stateless SecurityFilterChain, bcrypt password hashing, scoped authorization rules, and tests that pin every denied path.

Component Boundaries

  1. 01SecurityFilterChain filters run before DispatcherServlet, so rejected requests never reach a controller.
  2. 02The oauth2-resource-server starter validates JWT signature, issuer, audience, and exp on every request; HTTP sessions stay off.
  3. 03PasswordEncoderFactories emits {bcrypt} hashes with per-hash salts; raw passwords are compared once and never stored or logged.

Required Evidence

  • Token contract recorded: issuer, audience, clock skew, and required authorities such as task:create.
  • 401 and 403 are asserted as distinct test outcomes, never merged into one catch-all case.
  • Database audit shows every credential column holding a {bcrypt}-prefixed hash and no reversible values.

Implementation sequence

Step-by-Step Integration

Follow these structured steps to inject Core Spring functionality cleanly across your boundaries.

01

Declare a stateless SecurityFilterChain

SecurityConfiguration.java

WebSecurityConfigurerAdapter is gone in Spring Security 6; the lambda-style SecurityFilterChain bean is the supported configuration, and disabling CSRF fits token-bearing APIs that accept no cookies.

SecurityConfiguration.java
@Configuration
@EnableMethodSecurity
class SecurityConfiguration {
    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/tasks").hasAuthority("task:create")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }
}

Verify Configuration

curl -i -X POST http://localhost:8080/api/tasks with no token prints HTTP/1.1 401; the same call with a token lacking task:create prints 403; GET http://localhost:8080/actuator/health without a token still returns 200 {"status":"UP"}.

02

Store passwords behind the delegating bcrypt encoder

PasswordConfiguration.java

Encryption is reversible and unsalted MD5 or SHA-256 falls to commodity GPUs; bcrypt carries a salt and cost factor per hash, and the delegating encoder lets you migrate algorithms without breaking stored values.

PasswordConfiguration.java
@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

Verify Configuration

SELECT password FROM users LIMIT 1 returns a value beginning with {bcrypt}$2a$; searching application logs for the submitted plaintext password returns no matches.

03

Pin the authorization matrix in MockMvc

TaskSecurityTest.java

Happy-path-only suites hide broken deny rules; unauthenticated, unauthorized, and permitted requests must each assert their own status code.

TaskSecurityTest.java
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@SpringBootTest
@AutoConfigureMockMvc
class TaskSecurityTest {
    @Autowired MockMvc mvc;

    @Test
    void rejects_missing_token() throws Exception {
        mvc.perform(post("/api/tasks")).andExpect(status().isUnauthorized());
    }

    @Test
    void rejects_wrong_authority() throws Exception {
        mvc.perform(post("/api/tasks").with(jwt().authorities(new SimpleGrantedAuthority("task:read"))))
            .andExpect(status().isForbidden());
    }

    @Test
    void permits_creator() throws Exception {
        mvc.perform(post("/api/tasks").with(jwt().authorities(new SimpleGrantedAuthority("task:create"))))
            .andExpect(status().isCreated());
    }
}

Verify Configuration

./gradlew test --tests TaskSecurityTest reports separate green cases for 401 missing token, 403 wrong authority, and 201 permitted creator.

Synthesis & Production Verification

Production Acceptance Criteria

  • Unauthenticated calls get 401, insufficient scope gets 403
  • Passwords verify against {bcrypt} hashes and never log raw values
  • Expired and wrong-audience tokens are rejected with distinct evidence

Architectural & Technical Review

Core Interview Questions

Q01What HTTP security headers should be enforced on production API responses?Show answer

Content-Security-Policy (CSP), Strict-Transport-Security (HSTS), X-Content-Type-Options (nosniff), X-Frame-Options (DENY), and Referrer-Policy to prevent clickjacking and MIME-confusion attacks.

Q02How does AuditorAware integrate with Spring Security for entity auditing?Show answer

AuditorAware<String> extracts the authenticated username from SecurityContextHolder, automatically populating @CreatedBy and @LastModifiedBy fields on JPA entities.

Q03How should an automated security test matrix be structured in Spring Boot?Show answer

Use MockMvc with SecurityMockMvcRequestPostProcessors.jwt() to systematically verify that unauthenticated calls return 401, unauthorized roles return 403, and authorized roles return 200.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Relying solely on transport TLS without application-level method security — TLS encrypts the channel, but does not enforce principal permissions or tenancy boundaries.
  • Pitfall: Failing to verify 401 and 403 authorization matrix paths in automated tests — Testing only happy-path 200 responses risks leaving authorization holes completely undetected.

Module Certification Quiz

Prove Module Mastery

Comprehensive question set covering all lessons in this module.

Take Module Quiz

Primary Documentation & Specifications

Supplementary Module Resources