When the built-in username/password or JWT flows don't fit, you customize at two layers: a custom AuthenticationFilter to extract a credential from the request, and a custom AuthenticationProvider to verify it. Knowing which layer does what — and that they're separate — is the point of this question.
AuthenticationProvider — the verifier
Decides whether a credential is valid and produces a fully authenticated Authentication. The AuthenticationManager (ProviderManager) tries each registered provider whose supports(...) matches:
@Component
class ApiKeyAuthenticationProvider implements AuthenticationProvider {
private final ApiKeyService keys;
@Override
public Authentication authenticate(Authentication auth) {
String presented = (String) auth.getCredentials();
ApiKey key = keys.find(presented)
.orElseThrow(() -> new BadCredentialsException("invalid API key"));
// build an AUTHENTICATED token (note the authorities-bearing constructor)
return new ApiKeyAuthenticationToken(
key.owner(), null, key.authorities());
}
@Override
public boolean supports(Class<?> authentication) {
return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Throw an AuthenticationException (e.g. BadCredentialsException) to reject. Returning a token built with the authorities constructor marks it authenticated.
AuthenticationFilter — the extractor
Pulls the raw credential out of the HTTP request, wraps it in an unauthenticated token, and hands it to the manager. You usually extend OncePerRequestFilter (or use the generic AuthenticationFilter):
class ApiKeyFilter extends OncePerRequestFilter {
private final AuthenticationManager manager;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
String header = req.getHeader("X-API-Key");
if (header != null) {
var unauth = new ApiKeyAuthenticationToken(header); // not yet authenticated
Authentication result = manager.authenticate(unauth);
SecurityContextHolder.getContext().setAuthentication(result);
}
chain.doFilter(req, res);
}
}
Wiring them into the chain
http
.authenticationProvider(apiKeyProvider)
.addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class);
Division of labour
Filter = transport: where the credential lives in the request and where to store the result. Provider = policy: is this credential valid, and what authorities does it grant. Keep verification logic out of the filter so it's reusable and unit-testable.