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:
- Stateless, token-based authentication for microservices communication
- Fine-grained role and scope-based authorization
- MFA for sensitive operations (transfers, account changes)
- Audit logging of every access attempt
- Short-lived tokens with secure refresh flows
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:
@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.
@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.
Never skip MFA on operations that move money. Even internal service-to-service calls for high-value transactions should have full audit trails.
@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:
@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:
@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
- Deny by default — explicitly permit endpoints, never explicitly deny them
- Use step-up MFA only for high-risk operations to preserve UX
- JWT claims should carry business data (
customer_id,mfa_verified) — avoid extra DB calls in filters @PreAuthorizeis your backstop — add it to all controller methods- Always write security-specific tests. They're the only way to catch authorization regressions before production