spring-projects / spring-projects/spring-security
Custom Jackson deserializers re-introspect nested types on every call — allocation hotspot in `JdbcOAuth2AuthorizationService` and Spring Session JDBC paths (much amplified by Jackson 3)
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 9.6k
- Forks
- 6.3k
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 52
Description
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.UsernamePasswordAuthenticationTokenDeserializerorg.springframework.security.oauth2.server.authorization.jackson.OAuth2AuthorizationRequestDeserializerorg.springframework.security.oauth2.client.jackson.OAuth2AuthorizationRequestDeserializerorg.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.UsernamePasswordAuthenticationTokenDeserializerorg.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationRequestDeserializerorg.springframework.security.oauth2.client.jackson2.OAuth2AuthorizationRequestDeserializerorg.springframework.security.oauth2.client.jackson2.ClientRegistrationDeserializer
These deserializers run on hot paths:
- Every
/oauth2/tokenauthorization-code redemption:JdbcOAuth2AuthorizationService.findById/findByToken→JsonMapperOAuth2AuthorizationRowMapperreads theoauth2_authorization.attributesJSON, which contains anOAuth2AuthorizationRequestand a polymorphicPrincipal. - Every authenticated request that loads a session through Spring Session JDBC:
UsernamePasswordAuthenticationTokenDeserializer.deserializerebuilds theSecurityContext.
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 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: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 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 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):
- Branch: https://github.com/aschepp/spring-security/tree/issue/jackson-deserializer-allocation-repro
- Jackson 3 benchmark:
OAuth2AuthorizationRequestDeserializerAllocationBenchmark.java - Jackson 2 benchmark:
OAuth2AuthorizationRequestDeserializerJackson2AllocationBenchmark.java
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
mainat HEAD) - Spring Boot: 4.0.6 (production),
mainfor 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)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the two named allocation benchmarks in oauth2/oauth2-authorization-server/src/test/java/... and compare their allocation and latency output. Then read the four Jackson 3 deserializers listed in the issue, along with the existing OAuth2 and security deserialization tests. Done means the repeated row-shaped deserialization no longer shows POJO introspection in its hot path, allocation approaches the reported single-digit-kilobyte range, and the relevant module tests pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- backend, performance, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100