Banking applications handle some of the most sensitive data in the world. A single misconfigured security filter can expose customer accounts, transaction history, or payment credentials. After years working in the fintech space, I've learned that security isn't a feature you bolt on at the end — it's the foundation you build on from day one.

In this article I'll walk through the patterns I use to implement OAuth2 authorization flows and multi-factor authentication (MFA) in Spring Boot, with the specific requirements of banking-grade APIs in mind.

Why Default Spring Security Isn't Enough

Spring Security's defaults are a solid starting point — HTTP basic auth and CSRF protection out of the box. But in a banking context you need to go much further. Regulations like PCI-DSS demand:

// Key Insight

Treat every endpoint as public until you explicitly prove it's secured. The "deny by default" mental model saves you from authorization gaps that only show up in production.

Setting Up the Security Filter Chain

In Spring Boot 3.x with Spring Security 6, WebSecurityConfigurerAdapter is gone. You define a SecurityFilterChain bean instead. Here's a production-ready baseline:

java SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/api/v1/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthConverter())))
            .addFilterBefore(
                mfaValidationFilter(),
                UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(
                    new BearerTokenAuthenticationEntryPoint())
                .accessDeniedHandler(
                    new BearerTokenAccessDeniedHandler()))
            .build();
    }
}

OAuth2 with JWT: The Token Claims That Matter

For inter-service communication in a microservices platform, JWT tokens work well because they're self-contained — the receiving service validates the token without a database call. But the claims you include make all the difference.

java JwtAuthConverter.java
@Component
public class JwtAuthConverter
        implements Converter<Jwt, AbstractAuthenticationToken> {

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        Collection<GrantedAuthority> authorities = Stream.concat(
            extractRealmRoles(jwt).stream(),
            extractResourceRoles(jwt).stream()
        ).collect(Collectors.toSet());

        // Pull banking-specific claims from the token
        String customerId = jwt.getClaimAsString("customer_id");
        boolean mfaVerified = Boolean.TRUE.equals(
            jwt.getClaim("mfa_verified"));

        return new BankingAuthenticationToken(
            jwt, authorities, customerId, mfaVerified);
    }

    private Collection<GrantedAuthority> extractResourceRoles(Jwt jwt) {
        Map<String, Object> resourceAccess =
            jwt.getClaim("resource_access");
        if (resourceAccess == null) return Collections.emptySet();

        // Parse roles from your IdP (Keycloak, Okta, etc.)
        return roles.stream()
            .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
            .collect(Collectors.toSet());
    }
}

Step-Up Authentication: MFA Only Where It Matters

Requiring MFA on every request creates terrible UX. The pattern I've settled on is step-up authentication: users get a standard JWT on login, but sensitive operations require an additional mfa_verified: true claim in the token.

// Warning

Never skip MFA on operations that move money. Even internal service-to-service calls for high-value transactions should have full audit trails.

java MfaValidationFilter.java
@Component
public class MfaValidationFilter extends OncePerRequestFilter {

    private static final Set<String> MFA_PATHS = Set.of(
        "/api/v1/transfers",
        "/api/v1/accounts/close",
        "/api/v1/beneficiaries"
    );

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain)
            throws ServletException, IOException {

        boolean requiresMfa = MFA_PATHS.stream()
            .anyMatch(req.getServletPath()::startsWith);

        if (requiresMfa) {
            Authentication auth = SecurityContextHolder
                .getContext().getAuthentication();

            if (auth instanceof BankingAuthenticationToken bankingAuth
                    && !bankingAuth.isMfaVerified()) {

                res.setStatus(HttpStatus.FORBIDDEN.value());
                res.setContentType("application/json");
                res.getWriter().write("""
                    {
                      "error": "MFA_REQUIRED",
                      "message": "This operation requires step-up authentication"
                    }""");
                return;
            }
        }
        chain.doFilter(req, res);
    }
}

Method-Level Security: The Last Line of Defense

Even with a solid filter chain, I always add method-level security as a second layer. This catches authorization gaps when someone adds a new controller without thinking about it:

java TransferController.java
@RestController
@RequestMapping("/api/v1/transfers")
public class TransferController {

    // Only the account owner can initiate transfers
    @PreAuthorize("hasRole('CUSTOMER') and " +
                  "#req.sourceAccount.customerId == authentication.customerId")
    @PostMapping
    public ResponseEntity<TransferResponse> initiateTransfer(
            @Valid @RequestBody TransferRequest req) {
        // ...
    }

    // Admin or account owner can view history
    @PreAuthorize("hasRole('ADMIN') or " +
                  "#customerId == authentication.customerId")
    @GetMapping("/history/{customerId}")
    public ResponseEntity<Page<TransferDto>> getHistory(
            @PathVariable String customerId,
            Pageable pageable) {
        // ...
    }
}

Testing Your Security Configuration

Security logic without tests will eventually fail in production. Spring Security's test support makes this easy:

java TransferControllerSecurityTest.java
@WebMvcTest(TransferController.class)
@AutoConfigureMockMvc
class TransferControllerSecurityTest {

    @Autowired MockMvc mockMvc;

    @Test
    void transferWithoutMfa_shouldReturn403() throws Exception {
        mockMvc.perform(post("/api/v1/transfers")
                .with(jwt().jwt(j -> j
                    .claim("mfa_verified", false)
                    .claim("customer_id", "cust-123")))
                .contentType(MediaType.APPLICATION_JSON)
                .content(transferPayload()))
            .andExpect(status().isForbidden())
            .andExpect(jsonPath("$.error").value("MFA_REQUIRED"));
    }

    @Test
    void transferWithMfaAndCorrectOwner_shouldReturn201() throws Exception {
        mockMvc.perform(post("/api/v1/transfers")
                .with(jwt().jwt(j -> j
                    .claim("mfa_verified", true)
                    .claim("customer_id", "cust-123")
                    .claim("roles", List.of("CUSTOMER"))))
                .contentType(MediaType.APPLICATION_JSON)
                .content(transferPayload()))
            .andExpect(status().isCreated());
    }
}

Key Takeaways

Juan David Ortiz Trujillo
Juan David Ortiz Trujillo
// semi senior backend developer · globant

Backend developer specialized in banking solutions. 5+ years building secure, scalable microservices with Spring Boot, Java 17, and AWS.