Precising, applying and testing Times answer(+1) :
You could define an AuthenticationEntryPoint and use the given HttpServletResponse to write your response body as desired.
Extending (e.g) BasicAuthenticationEntryPoint (not many configurations send this "WWW-Authenticate" header) like so:
private static AuthenticationEntryPoint authenticationEntryPoint() {
  BasicAuthenticationEntryPoint result = new BasicAuthenticationEntryPoint() {
    // inline:
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
      response.addHeader( // identic/similar to super method
          "WWW-Authenticate", String.format("Basic realm=\"%s\"", getRealmName())
      );
      // subtle difference:
      response.setStatus(HttpStatus.UNAUTHORIZED.value() /*, no message! */);
      // "print" custom to "response":
      response.getWriter().format(
          "{\"error\":{\"message\":\"%s\"}}", authException.getMessage()
      );
    }
  };
  // basic specific/default:
  result.setRealmName("Realm");
  return result;
}
These tests pass:
package com.example.security.custom.entrypoint;
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;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@AutoConfigureMockMvc
@SpringBootTest(properties = {"spring.security.user.password=!test2me"})
class SecurityCustomEntrypointApplicationTests {
  @Autowired
  private MockMvc mvc;
  @Test
  public void testWrongCredentials() throws Exception {
    mvc
        .perform(get("/secured").with(httpBasic("unknown", "wrong")))
        .andDo(print())
        .andExpectAll(
            unauthenticated(),
            status().isUnauthorized(),
            header().exists("WWW-Authenticate"),
            content().bytes(new byte[0]) // !! no content
        );
  }
  @Test
  void testCorrectCredentials() throws Exception {
    mvc
        .perform(get("/secured").with(httpBasic("user", "!test2me")))
        .andDo(print())
        .andExpectAll(
            status().isOk(),
            content().string("Hello")
        );
  }
  @Test
  void testNoCredentials() throws Exception {
    mvc
        .perform(get("/secured"))
        .andDo(print())
        .andExpectAll(
            status().isUnauthorized(),
            header().exists("WWW-Authenticate"),
            jsonPath("$.error.message").exists()
        );
  }
}
On this (full) app:
package com.example.security.custom.entrypoint;
import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import static org.springframework.security.config.Customizer.withDefaults;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@SpringBootApplication
public class SecurityCustomEntrypointApplication {
  public static void main(String[] args) {
    SpringApplication.run(SecurityCustomEntrypointApplication.class, args);
  }
  @Controller
  static class SecuredController {
    @GetMapping("secured")
    @ResponseBody
    public String secured() {
      return "Hello";
    }
  }
  @Configuration
  static class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
      return http
          .authorizeRequests()
          .anyRequest().authenticated()
          .and()
          .httpBasic(withDefaults())
          .exceptionHandling()
          .authenticationEntryPoint(authenticationEntryPoint()) 
          // ...
          .and().build();
    }
    // @Bean (and/) or static...: you decide!;)
    private static AuthenticationEntryPoint authenticationEntryPoint() {
      BasicAuthenticationEntryPoint result = new BasicAuthenticationEntryPoint() {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
          response.addHeader(
              "WWW-Authenticate", String.format("Basic realm=\"%s\"", getRealmName())
          );
          response.setStatus(HttpStatus.UNAUTHORIZED.value());
          response.getWriter().format(
              "{\"error\":{\"message\":\"%s\"}}", authException.getMessage()
          );
        }
      };
      result.setRealmName("Realm");
      return result;
    }
  }
}
To make it work for "wrong credentials" and "basic authentication" (testWrongCredentials() expect json body, in form authentication it'd be easier/different (http.formLogin().failureHandler((req, resp, exc)->{/*your code here*/})...)), or as answer to: "How to override BasicAuthenticationFilter.on[Uns|S]uccessfulAuthentication(req,resp,exc) in spring security?" (originally they are empty/no-op), we should do:
//@Bean possible resp. needed, when autowire.., for simplicity, just:
private static BasicAuthenticationFilter customBasicAuthFilter(AuthenticationManager authenticationManager) {
  return new BasicAuthenticationFilter(authenticationManager
     /*, entryPoint */) {
    @Override
    protected void onUnsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
      System.err.println("Aha!");
      writeToResponse(response, failed); 
    }
    // @Override ...
  };
}
// with:
private static void writeToResponse(HttpServletResponse response, Exception failed) throws IOException {
  response.getWriter().format(
      "{\"error\":{\"message\":\"%s\"}}", failed.getMessage()
  );
}
This we can use in our filterChain like:
http.addFilter(customBasicAuthFilter(authenticationManager));
IMPORTANT:
- filter should be added before 
.httpBasic(...)! 
BasicAuthenticationFilter also accepts a AuthenticationEntryPoint  ..., but it is/does not the same as .exceptionHandling().authenticationEntryPoint(...). 
Actually this implicitly answers how to override any XXXFilter#anyVisibleMethod in any spring-security filter;).
To work around "spring-security-without-the-websecurityconfigureradapter", I stuffed it into a "custom dsl" like (otherwise i get authenticationManager==null/circular refs;(:
static class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {
  @Override
  public void configure(HttpSecurity http) throws Exception {
    AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
    http.addFilter(customBasicAuthFilter(authenticationManager));
  }
  public static CustomDsl customDsl() {
    return new CustomDsl();
  }
}
To use it finally like:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  return http
      .authorizeRequests()
      .anyRequest().authenticated()
      .and()
      .apply(CustomDsl.customDsl()) // before httpBasic()!
      .and()
      .httpBasic(withDefaults())
      .exceptionHandling() // this is still needed ...
      .authenticationEntryPoint(authenticationEntryPoint()) // ... for the "anonymous" (test) case!
      .and()
      .build();
}
Then we can also modify/expect:
@Test
public void testWrongCredentials() throws Exception {
  mvc
      .perform(get("/secured").with(httpBasic("unknown", "wrong")))
      .andDo(print())
      .andExpectAll(
          unauthenticated(),
          status().isUnauthorized(),
          header().exists("WWW-Authenticate"),
          jsonPath("$.error.message").exists() // !#
      );
}