Describe the bug
Several custom ValueDeserializer / JsonDeserializer implementations under org.springframework.security.jackson.*, org.springframework.security.jackson2.*, org.springframework.security.oauth2.*.jackson.* and org.springframework.security.oauth2.*.jackson2.* resolve their nested deserializers inside deserialize(...) on every invocation rather than once per deserializer lifetime. Because Jackson's DeserializerCache._findCachedDeserializer short-circuits whenever _hasCustomHandlers(type) returns true (see databind #735) — and the polymorphic value types Spring Security passes in (Map<String, Object>, Set<String>, List<GrantedAuthority>, Object for principal/details) trigger exactly that condition — the lookup is never cached, so every call re-runs full POJO introspection (POJOPropertiesCollector → AnnotatedMethodCollector._addMemberMethods → reflective generic-signature parsing).
Affected classes (the ones I traced via JFR and verified with the benchmark below; others in the same packages likely follow the same pattern):
Jackson 3 (tools.jackson):
org.springframework.security.jackson.UsernamePasswordAuthenticationTokenDeserializer
org.springframework.security.oauth2.server.authorization.jackson.OAuth2AuthorizationRequestDeserializer
org.springframework.security.oauth2.client.jackson.OAuth2AuthorizationRequestDeserializer
org.springframework.security.oauth2.client.jackson.ClientRegistrationDeserializer
Jackson 2 (com.fasterxml.jackson) — same architectural issue, lower per-call cost — these are @Deprecated(forRemoval = true) so most likely out of scope for a fix:
org.springframework.security.jackson2.UsernamePasswordAuthenticationTokenDeserializer
org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationRequestDeserializer
org.springframework.security.oauth2.client.jackson2.OAuth2AuthorizationRequestDeserializer
org.springframework.security.oauth2.client.jackson2.ClientRegistrationDeserializer
These deserializers run on hot paths:
- Every
/oauth2/token authorization-code redemption: JdbcOAuth2AuthorizationService.findById / findByToken → JsonMapperOAuth2AuthorizationRowMapper reads the oauth2_authorization.attributes JSON, which contains an OAuth2AuthorizationRequest and a polymorphic Principal.
- Every authenticated request that loads a session through Spring Session JDBC:
UsernamePasswordAuthenticationTokenDeserializer.deserialize rebuilds the SecurityContext.
The dominant JFR allocation stack on a production Spring Boot 4 / Spring Security 7 service under steady traffic ends in AnnotatedMethodCollector._addMemberMethods and is reached via JsonNodeUtils.findValue → DeserializationContext.readTreeAsValue → DeserializerCache._createAndCacheValueDeserializer (the cache-bypass branch). A second equivalent stack starts from UsernamePasswordAuthenticationTokenDeserializer.deserialize whenever Spring Session JDBC loads a session. Together these account for ~25 % of total JVM allocation in a representative 60-second JFR sample of the deployment that triggered this investigation.
Why now: this is amplified by the Jackson 3 migration, but the architectural issue predates it
A microbenchmark on main (JDK 25.0.3) using a JSON fixture that mirrors a representative oauth2_authorization.attributes payload (auth-code grant, one scope, a custom UserDetails principal with three fields, one FactorGrantedAuthority, a WebAuthenticationDetails; ~1.7 KB serialized) was run against both the Jackson 2 path (com.fasterxml.jackson + SecurityJackson2Modules) and the Jackson 3 path (tools.jackson + SecurityJacksonModules), without any other code change:
|
Jackson 2 (existing baseline) |
Jackson 3 (current default) |
Ratio |
Allocation per mapper.readValue |
28,906 bytes |
105,982 bytes |
3.67× worse on Jackson 3 |
| Latency per call |
11,384 ns |
34,121 ns |
3.00× slower on Jackson 3 |
| Total allocation, 50,000 iters |
1.45 GB |
5.30 GB |
|
| Total wall time, 50,000 iters |
569 ms |
1,706 ms |
|
The same JsonNodeUtils.findValue → ObjectMapper.convertValue / DeserializationContext.readTreeAsValue → DeserializerCache.findValueDeserializer path exists in both, and both fail to cache the deserializer for these polymorphic container types — the cache bypass is by design and has been in Jackson since 2.x. What changed is the per-miss cost: Jackson 3's introspection (POJOPropertiesCollector.collectAll, generic-signature parsing) is ~3–4× heavier than Jackson 2's. Deployments on Jackson 2 were paying this cost too, but at a level small enough to absorb invisibly; the Spring Boot 4 bump to Jackson 3 pushed it across the threshold where it becomes a visible production GC hotspot — in one observed case, recurring OOMs.
Potential improvement headroom
A local experiment (in a private fork) modified the four Jackson 3 deserializers above to resolve their nested deserializers exactly once instead of on every call. The microbenchmark on the same fixture and JVM, identical methodology:
|
Jackson 3 — main |
Jackson 3 — local experiment |
Reduction |
Allocation per mapper.readValue |
105,982 bytes |
11,769 bytes |
−88.9 % |
| Latency per call |
34,121 ns |
6,074 ns |
−82.2 % |
| Total allocation, 50,000 iters |
5.3 GB |
588 MB |
|
| Total wall time, 50,000 iters |
1,706 ms |
303 ms |
|
After the experimental change the per-call profile no longer shows AnnotatedMethodCollector._addMemberMethods / POJOPropertiesCollector.collectAll / generic-signature parsing in the hot stack — i.e. the introspection cost is paid once per deserializer construction, not per call. All existing tests in spring-security-core, spring-security-oauth2-client, and spring-security-oauth2-authorization-server pass under the change.
I'm not proposing a specific implementation as part of this report — the maintainers are better placed to choose the approach (e.g. lifecycle-callback pre-resolution, alternate helper APIs in JsonNodeUtils, a structural change to the affected deserializers, etc.). The numbers above are intended to show the size of the achievable headroom on Jackson 3, not to advocate for one technique over another.
To Reproduce
-
Have any application backed by JdbcOAuth2AuthorizationService (or Spring Session JDBC with UsernamePasswordAuthenticationToken principals) on Spring Boot 4.0.x / Spring Security 7.0.x.
-
Build a JsonMapper configured with SecurityJacksonModules.getModules(...) (the standard configuration used by the JDBC services above).
-
Serialize a representative attributes-style Map<String, Object> containing both an OAuth2AuthorizationRequest and an authenticated UsernamePasswordAuthenticationToken, then deserialize it repeatedly:
String json = mapper.writeValueAsString(attributes);
for (int i = 0; i < N; i++) {
mapper.readValue(json, Map.class);
}
-
Measure thread-local allocation with com.sun.management.ThreadMXBean.getThreadAllocatedBytes, or attach async-profiler -e alloc / JFR (settings=profile) and inspect allocation hot spots. Each invocation allocates ~100 KiB on Jackson 3 (~29 KiB on Jackson 2); the dominant stacks all end in AnnotatedMethodCollector._addMemberMethods reached from a Spring Security custom deserializer.
Self-contained microbenchmarks for both Jackson 2 and Jackson 3 (one file each, synthetic identifiers only) are included in the sample below.
Expected behavior
The cost of resolving these nested deserializers should be amortized across calls — the per-call allocation profile should not include POJO introspection (AnnotatedMethodCollector._addMemberMethods, POJOPropertiesCollector.collectAll, generic-signature parsing) for the Set<String>, Map<String, Object>, List<GrantedAuthority> and Object types these deserializers consume.
Concretely, after a fix, allocation per mapper.readValue for a row-shaped payload like the one used here should be on the order of single-digit kilobytes rather than ~100 KB (Jackson 3) / ~29 KB (Jackson 2). The local experiment described above reaches ~12 KB on Jackson 3.
The implementation approach is at the maintainers' discretion.
Sample
Minimal reproducer — two self-contained Java files (one per Jackson version) on a branch off upstream/main, no production-code changes. All identifiers are synthetic (UUID.randomUUID() / 127.0.0.1 / example.com):
Run with:
./gradlew :spring-security-oauth2-authorization-server:test \
--tests "*OAuth2AuthorizationRequestDeserializerAllocationBenchmark" \
--tests "*OAuth2AuthorizationRequestDeserializerJackson2AllocationBenchmark" \
-i
Each benchmark prints alloc_per_call_bytes and ns_per_call to stdout — before/after comparison is a single line of output difference.
Environment
- Spring Security: 7.0.5 (also reproduces on
main at HEAD)
- Spring Boot: 4.0.6 (production),
main for benchmark
- Jackson: 3.1.3 (transitive from Spring Boot 4.0.6) and 2.x (comparison baseline)
- JDK: Eclipse Temurin 25.0.3 (also reproduces on Temurin 21)
Describe the bug
Several custom
ValueDeserializer/JsonDeserializerimplementations underorg.springframework.security.jackson.*,org.springframework.security.jackson2.*,org.springframework.security.oauth2.*.jackson.*andorg.springframework.security.oauth2.*.jackson2.*resolve their nested deserializers insidedeserialize(...)on every invocation rather than once per deserializer lifetime. Because Jackson'sDeserializerCache._findCachedDeserializershort-circuits whenever_hasCustomHandlers(type)returnstrue(see databind #735) — and the polymorphic value types Spring Security passes in (Map<String, Object>,Set<String>,List<GrantedAuthority>,Objectfor principal/details) trigger exactly that condition — the lookup is never cached, so every call re-runs full POJO introspection (POJOPropertiesCollector→AnnotatedMethodCollector._addMemberMethods→ reflective generic-signature parsing).Affected classes (the ones I traced via JFR and verified with the benchmark below; others in the same packages likely follow the same pattern):
Jackson 3 (
tools.jackson):org.springframework.security.jackson.UsernamePasswordAuthenticationTokenDeserializerorg.springframework.security.oauth2.server.authorization.jackson.OAuth2AuthorizationRequestDeserializerorg.springframework.security.oauth2.client.jackson.OAuth2AuthorizationRequestDeserializerorg.springframework.security.oauth2.client.jackson.ClientRegistrationDeserializerJackson 2 (
com.fasterxml.jackson) — same architectural issue, lower per-call cost — these are@Deprecated(forRemoval = true)so most likely out of scope for a fix:org.springframework.security.jackson2.UsernamePasswordAuthenticationTokenDeserializerorg.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationRequestDeserializerorg.springframework.security.oauth2.client.jackson2.OAuth2AuthorizationRequestDeserializerorg.springframework.security.oauth2.client.jackson2.ClientRegistrationDeserializerThese deserializers run on hot paths:
/oauth2/tokenauthorization-code redemption:JdbcOAuth2AuthorizationService.findById/findByToken→JsonMapperOAuth2AuthorizationRowMapperreads theoauth2_authorization.attributesJSON, which contains anOAuth2AuthorizationRequestand a polymorphicPrincipal.UsernamePasswordAuthenticationTokenDeserializer.deserializerebuilds theSecurityContext.The dominant JFR allocation stack on a production Spring Boot 4 / Spring Security 7 service under steady traffic ends in
AnnotatedMethodCollector._addMemberMethodsand is reached viaJsonNodeUtils.findValue → DeserializationContext.readTreeAsValue → DeserializerCache._createAndCacheValueDeserializer(the cache-bypass branch). A second equivalent stack starts fromUsernamePasswordAuthenticationTokenDeserializer.deserializewhenever Spring Session JDBC loads a session. Together these account for ~25 % of total JVM allocation in a representative 60-second JFR sample of the deployment that triggered this investigation.Why now: this is amplified by the Jackson 3 migration, but the architectural issue predates it
A microbenchmark on
main(JDK 25.0.3) using a JSON fixture that mirrors a representativeoauth2_authorization.attributespayload (auth-code grant, one scope, a customUserDetailsprincipal with three fields, oneFactorGrantedAuthority, aWebAuthenticationDetails; ~1.7 KB serialized) was run against both the Jackson 2 path (com.fasterxml.jackson+SecurityJackson2Modules) and the Jackson 3 path (tools.jackson+SecurityJacksonModules), without any other code change:mapper.readValueThe same
JsonNodeUtils.findValue → ObjectMapper.convertValue / DeserializationContext.readTreeAsValue → DeserializerCache.findValueDeserializerpath exists in both, and both fail to cache the deserializer for these polymorphic container types — the cache bypass is by design and has been in Jackson since 2.x. What changed is the per-miss cost: Jackson 3's introspection (POJOPropertiesCollector.collectAll, generic-signature parsing) is ~3–4× heavier than Jackson 2's. Deployments on Jackson 2 were paying this cost too, but at a level small enough to absorb invisibly; the Spring Boot 4 bump to Jackson 3 pushed it across the threshold where it becomes a visible production GC hotspot — in one observed case, recurring OOMs.Potential improvement headroom
A local experiment (in a private fork) modified the four Jackson 3 deserializers above to resolve their nested deserializers exactly once instead of on every call. The microbenchmark on the same fixture and JVM, identical methodology:
mainmapper.readValueAfter the experimental change the per-call profile no longer shows
AnnotatedMethodCollector._addMemberMethods/POJOPropertiesCollector.collectAll/ generic-signature parsing in the hot stack — i.e. the introspection cost is paid once per deserializer construction, not per call. All existing tests inspring-security-core,spring-security-oauth2-client, andspring-security-oauth2-authorization-serverpass under the change.I'm not proposing a specific implementation as part of this report — the maintainers are better placed to choose the approach (e.g. lifecycle-callback pre-resolution, alternate helper APIs in
JsonNodeUtils, a structural change to the affected deserializers, etc.). The numbers above are intended to show the size of the achievable headroom on Jackson 3, not to advocate for one technique over another.To Reproduce
Have any application backed by
JdbcOAuth2AuthorizationService(or Spring Session JDBC withUsernamePasswordAuthenticationTokenprincipals) on Spring Boot 4.0.x / Spring Security 7.0.x.Build a
JsonMapperconfigured withSecurityJacksonModules.getModules(...)(the standard configuration used by the JDBC services above).Serialize a representative
attributes-styleMap<String, Object>containing both anOAuth2AuthorizationRequestand an authenticatedUsernamePasswordAuthenticationToken, then deserialize it repeatedly:Measure thread-local allocation with
com.sun.management.ThreadMXBean.getThreadAllocatedBytes, or attachasync-profiler -e alloc/ JFR (settings=profile) and inspect allocation hot spots. Each invocation allocates ~100 KiB on Jackson 3 (~29 KiB on Jackson 2); the dominant stacks all end inAnnotatedMethodCollector._addMemberMethodsreached from a Spring Security custom deserializer.Self-contained microbenchmarks for both Jackson 2 and Jackson 3 (one file each, synthetic identifiers only) are included in the sample below.
Expected behavior
The cost of resolving these nested deserializers should be amortized across calls — the per-call allocation profile should not include POJO introspection (
AnnotatedMethodCollector._addMemberMethods,POJOPropertiesCollector.collectAll, generic-signature parsing) for theSet<String>,Map<String, Object>,List<GrantedAuthority>andObjecttypes these deserializers consume.Concretely, after a fix, allocation per
mapper.readValuefor a row-shaped payload like the one used here should be on the order of single-digit kilobytes rather than ~100 KB (Jackson 3) / ~29 KB (Jackson 2). The local experiment described above reaches ~12 KB on Jackson 3.The implementation approach is at the maintainers' discretion.
Sample
Minimal reproducer — two self-contained Java files (one per Jackson version) on a branch off
upstream/main, no production-code changes. All identifiers are synthetic (UUID.randomUUID()/127.0.0.1/example.com):OAuth2AuthorizationRequestDeserializerAllocationBenchmark.javaOAuth2AuthorizationRequestDeserializerJackson2AllocationBenchmark.javaRun with:
./gradlew :spring-security-oauth2-authorization-server:test \ --tests "*OAuth2AuthorizationRequestDeserializerAllocationBenchmark" \ --tests "*OAuth2AuthorizationRequestDeserializerJackson2AllocationBenchmark" \ -iEach benchmark prints
alloc_per_call_bytesandns_per_callto stdout — before/after comparison is a single line of output difference.Environment
mainat HEAD)mainfor benchmark