Spring security

15 de octubre 2025ComentariosjavaDavid Poza SuárezComentarios

I've been working with Java and Spring over the past year. And one of those things that I find both important and genuinely interesting is understanding authentication flows. We deal with them in every single project, and in Spring we manage them using Spring Security starter.

As usual I've been writing some code to put this into practise. I'm currently writing a full system for the management of Makespace Madrid, essentially a workshop managment system, with some entities such as parts, tools, locations, qr codes, datasheets, history of inputs and outputs, and of course, users with permissions that will be managed using Spring Security.

This application is based on an API Rest using Java Spring Boot, and will eventually include an MCP (Model Context Protocol) server, an Angular SPA and a native Android application written using React Native.

For this first approach, the auth system will be based on self generated JWT tokens, but in a second iteration that would be replaced by KeyCloak. The big picture for now is:

first iteration

first iteration

Spring security architecture

Spring MVC uses the servlet model, specifically a DispatcherServlet, which is deployed on a web server (the container) like Tomcat or Weblogic. DispatcherServlet is a Java class in charge of redirecting the requests to the controllers, and Spring security is based on servlet filters, which intercept the request before getting into the controller.

The following diagram represents the flow in a Spring application when Spring security uses two providers. The scenario for my application uses authentication via JWT token or via ApiKey:

flow with both ApiKey and User+Password providers

flow with both ApiKey and User+Password providers

Let's examine the different parts:

Jwt authorization (DaoAuthenticationProvider)

First, we'll focus in the JWT, which involves a JwtAuthenticationFilter (extends from UsernamePasswordAuthenticationFilter) and JwtValidationFilter (extends from BasicAuthenticationFilter). They differ in "when" they are executed.

JwtAuthenticationFilter runs only during the login, that's on /login controller, and it issues the token. On the other hand, JwtValidationFilter is executed on every protected request and checks whether the token is valid.

DaoAuthenticationProvider is the default provider when we define a UserDetailsService bean. It retrieves the user from a data source, like a database. It compares the password with the stored one.

The UserDetailsService for us is as follows:

@Service
public class JpaUserDetailsService implements UserDetailsService {

	@Autowired
	private UserRepository userRepository;

	@Transactional(readOnly = true)
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		Optional<User> userOpt = userRepository.findByUsername(username);

		if (userOpt.isEmpty()) {
			throw new UsernameNotFoundException(String.format("user %s not found", username));
		}
		User user = userOpt.orElseThrow();
		List<GrantedAuthority> authorities = user.getRoles().stream()
				.map(role -> new SimpleGrantedAuthority(role.getName())).collect(Collectors.toList());
		return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), true,
				true, true, true, authorities);
	}

}
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
	private AuthenticationManager authenticationManager;

	public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
		this.authenticationManager = authenticationManager;
		setAuthenticationManager(authenticationManager);
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse reponse)
			throws AuthenticationException {
		User user = null;
		String username = null;
		String password = null;

		try {
			user = new ObjectMapper().readValue(request.getInputStream(), User.class);
			username = user.getUsername();
			password = user.getPassword();
		} catch (StreamReadException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (DatabindException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

		return authenticationManager.authenticate(token);

	}

	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		org.springframework.security.core.userdetails.User user = (org.springframework.security.core.userdetails.User) authResult
				.getPrincipal();
		String username = user.getUsername();

		Collection<? extends GrantedAuthority> roles = authResult.getAuthorities();

		Claims claims = Jwts.claims().add("authorities", new ObjectMapper().writeValueAsString(roles))
				.add("username", username).build();

		String token = Jwts.builder().issuedAt(new Date()).expiration(new Date(System.currentTimeMillis() + 3600000))
				.subject(username).claims(claims).signWith(JwtConfig.SECRET_KEY).compact();

		response.addHeader(JwtConfig.AUTH_HEADER, JwtConfig.AUTH_HEADER_PREFIX + token);
		response.setStatus(200);
		response.setContentType(JwtConfig.CONTENT_TYPE);
	}

	@Override
	protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException failed) throws IOException, ServletException {
		response.setStatus(401);
		response.setContentType("application/json");
	}

}
public class JwtValidationFilter extends BasicAuthenticationFilter {

	public JwtValidationFilter(AuthenticationManager authenticationManager) {
		super(authenticationManager);
	}

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

		try {
			String header = request.getHeader("Authorization");
			if (header == null || !header.startsWith(JwtConfig.AUTH_HEADER_PREFIX)) {
				chain.doFilter(request, response);
				return;
			}
			String token = header.replace(JwtConfig.AUTH_HEADER_PREFIX, "");
			Claims claims = Jwts.parser().verifyWith(JwtConfig.SECRET_KEY).build().parseSignedClaims(token)
					.getPayload();
			String username = claims.getSubject();
			Object authoritiesClaims = claims.get("authorities");

			Collection<? extends GrantedAuthority> authorities = Arrays.asList(
					new ObjectMapper().addMixIn(SimpleGrantedAuthority.class, SimpleGrantedAuthorityJsonCreator.class)
							.readValue(authoritiesClaims.toString().getBytes(), SimpleGrantedAuthority[].class));
			UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, null,
					authorities);
			SecurityContextHolder.getContext().setAuthentication(authToken);
			chain.doFilter(request, response);
		} catch (JwtException e) {
			Map<String, String> body = new HashMap<>();
			body.put("error", e.getMessage());
			body.put("message", "El token JWT es invalido!");

			response.getWriter().write(new ObjectMapper().writeValueAsString(body));
			response.setStatus(HttpStatus.UNAUTHORIZED.value());
			response.setContentType(JwtConfig.CONTENT_TYPE);
		}
	}
}

Api key authentication (ApiKeyAuthenticationProvider)

public class ApiKeyAuthenticationProvider implements AuthenticationProvider {
	private final ApiKeyService apiKeyService;

	public ApiKeyAuthenticationProvider(ApiKeyService apiKeyService) {
		this.apiKeyService = apiKeyService;
	}

	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		var token = (ApiKeyAuthenticationToken) authentication;
		var key = (String) token.getPrincipal();
		var secret = (String) token.getCredentials();

		var record = apiKeyService.findActiveByKey(key + "." + secret)
				.orElseThrow(() -> new BadCredentialsException("Invalid API key"));

		return new ApiKeyAuthenticationToken(key, secret, record.authorities());
	}

	@Override
	public boolean supports(Class<?> authentication) {
		return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication);
	}
}

Finally, we need to configure Spring Security and its AuthenticationManager bean, which receives ApiKeyAuthenticationProvider and needs also the dependencies of DaoAuthenticationProvider (UserDetailsService and BCryptPasswordEncoder). We define the bean this way because we want to specify the ProviderManager, so we can control the list of available providers.

We also create a bean per filter and also a SecurityFilterChain bean, which defines:

  • which endpoints are anonymously accesible and which ones require an authenticated user.
  • the filters, and the order of execution for those (using addFilterBefore we guarantee that execution of ApiKeyAuthenticationFilter happends before JwtAuthenticationFilter).
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig {
	@Autowired
	private AuthenticationConfiguration authenticationConfiguration;

	@Bean
	AuthenticationManager authenticationManager(UserDetailsService uds, BCryptPasswordEncoder encoder,
			ApiKeyAuthenticationProvider apiKeyProvider) {
		var dao = new org.springframework.security.authentication.dao.DaoAuthenticationProvider();
		dao.setUserDetailsService(uds);
		dao.setPasswordEncoder(encoder);

		// Orden: primero API Key (si aplica), luego login user/pass (DAO)
		return new ProviderManager(java.util.List.of(apiKeyProvider, dao));
	}

	@Bean
	JwtAuthenticationFilter jwtAuthenticationFilter(AuthenticationManager authenticationManager) {
		return new JwtAuthenticationFilter(authenticationManager);
	}

	@Bean
	JwtValidationFilter jwtValidationFilter(AuthenticationManager authenticationManager) {
		return new JwtValidationFilter(authenticationManager);
	}

	@Bean
	ApiKeyAuthenticationFilter apiKeyAuthenticationFilter(AuthenticationManager authenticationManager) {
		return new ApiKeyAuthenticationFilter(authenticationManager);
	}

	@Bean
	SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter,
			JwtValidationFilter jwtValidationFilter, ApiKeyAuthenticationFilter apiKeyAuthenticationFilter)
			throws Exception {
		http.cors(
				httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()))
				.authorizeHttpRequests(authorizeRequests -> authorizeRequests
						.requestMatchers(HttpMethod.GET, "/v3/api-docs/**", // Para Swagger 3
								"/swagger-resources/**", "/swagger-ui/**", "/webjars/**", "/swagger-ui.html",
								"/files/photo/**", "/files/document/**")
						.permitAll().requestMatchers(HttpMethod.POST, "/users/register", "/login").permitAll()
						.anyRequest().authenticated())
				.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
				.addFilterBefore(apiKeyAuthenticationFilter, JwtAuthenticationFilter.class)
				.addFilter(jwtAuthenticationFilter).addFilter(jwtValidationFilter).csrf(csrf -> csrf.disable());
		return http.build();
	}

	@Bean
	BCryptPasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

I'll continue this series with the use of KeyCloak in order to implement the same JWT support and also add PKCE, so I can expose an MCP server from a public url, to be consume by ChatGPT.

See you soon.