This guide covers migrating applications using the Spring User Framework between major versions.
- Migration Guide
- Table of Contents
- Migrating to 4.0.x (Spring Boot 4.0)
- For Developers Extending the Framework
- Troubleshooting
- Version Compatibility Matrix
This section covers migrating from Spring User Framework 3.x (Spring Boot 3.x) to 4.x (Spring Boot 4.0).
Before starting the migration:
- Ensure your application is running on the latest 3.5.x version
- Review your custom security configurations
- Audit any code that extends framework classes
- Back up your database (schema changes are minimal but recommended)
Spring Boot 4.0 requires Java 21 or higher.
Update your build configuration:
Gradle:
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}Maven:
<properties>
<java.version>21</java.version>
</properties>Ensure your CI/CD pipelines and deployment environments support Java 21.
Update the framework dependency version:
Gradle:
implementation 'com.digitalsanctuary:ds-spring-user-framework:4.0.0'Maven:
<dependency>
<groupId>com.digitalsanctuary</groupId>
<artifactId>ds-spring-user-framework</artifactId>
<version>4.0.0</version>
</dependency>Update Spring Boot:
plugins {
id 'org.springframework.boot' version '4.0.0'
}The framework's transitive Passay dependency was upgraded from 1.x to 2.0.0, which relocated
several packages (e.g. org.passay.CharacterData → org.passay.data.CharacterData,
org.passay.CharacterRule → org.passay.rule.CharacterRule). This only affects you if your
application uses Passay directly (e.g. for custom password rules):
- If you declared your own
org.passay:passaydependency at a 1.x version, remove the explicit pin (let it inherit 2.0.0 transitively) or bump it to2.0.0. Pinning an older version forces a conflicting downgrade that breaks the framework'sPasswordPolicyServiceat runtime (ClassNotFoundException: org.passay.data.CharacterData). - Update your own Passay imports to the new 2.0.0 package names.
Applications that do not use Passay directly need no changes.
Spring Boot 4.0 includes Spring Security 7, which has breaking changes from Spring Security 6.x.
All URL patterns must now start with /.
This affects:
user.security.unprotectedURIsconfigurationuser.security.protectedURIsconfiguration- Any custom security matchers in your code
Before (3.x):
user:
security:
unprotectedURIs: /,/index.html,/css/**,/js/**,error,error.htmlAfter (4.x):
user:
security:
unprotectedURIs: /,/index.html,/css/**,/js/**,/error,/error.htmlNote the /error and /error.html now have leading slashes.
If you have custom security configuration extending or working with the framework:
Deprecated methods removed:
authorizeRequests()→ useauthorizeHttpRequests()antMatchers()→ userequestMatchers()mvcMatchers()→ userequestMatchers()
Example migration:
// Before (3.x)
http.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated();
// After (4.x)
http.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated());Spring Boot 4.0 introduces modular test packages.
Add the new modular test starters:
Gradle:
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-webmvc-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.security:spring-security-test'Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-webmvc-test</artifactId>
<scope>test</scope>
</dependency>Update imports for test annotations:
| Annotation | Old Package (3.x) | New Package (4.x) |
|---|---|---|
@AutoConfigureMockMvc |
org.springframework.boot.test.autoconfigure.web.servlet |
org.springframework.boot.webmvc.test.autoconfigure |
@WebMvcTest |
org.springframework.boot.test.autoconfigure.web.servlet |
org.springframework.boot.webmvc.test.autoconfigure |
@DataJpaTest |
org.springframework.boot.test.autoconfigure.orm.jpa |
org.springframework.boot.data.jpa.test.autoconfigure |
@AutoConfigureTestDatabase |
org.springframework.boot.test.autoconfigure.jdbc |
org.springframework.boot.jdbc.test.autoconfigure |
Example:
// Before (3.x)
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
// After (4.x)
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;Spring Boot 4.0 uses Jackson 3.x for JSON processing.
ObjectMapper instantiation:
// Before (Jackson 2.x)
ObjectMapper mapper = new ObjectMapper();
// After (Jackson 3.x)
ObjectMapper mapper = JsonMapper.builder().build();Package changes:
- Some classes moved from
com.fasterxml.jacksonto new packages - Check any custom serializers/deserializers
The /user/updateUser endpoint now accepts UserProfileUpdateDto instead of UserDto.
Before (3.x):
POST /user/updateUser
{
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"password": "...",
"matchingPassword": "..."
}After (4.x):
POST /user/updateUser
{
"firstName": "John",
"lastName": "Doe"
}This change improves security by not requiring password fields for profile updates.
Update your frontend code if you're calling this endpoint directly.
Review your application.yml for any deprecated properties:
| Deprecated Property | Replacement |
|---|---|
| (none currently) | - |
Most configuration properties remain unchanged between 3.x and 4.x.
If you've extended framework classes or implemented custom functionality, review these sections carefully.
If you have a custom WebSecurityConfig or extend the framework's security configuration:
- Ensure all URL patterns start with
/ - Update to lambda DSL style (required in Spring Security 7)
- Review method security annotations -
@PreAuthorize,@PostAuthorizeunchanged
The library contributes its SecurityFilterChain through a dedicated auto-configuration with two important properties:
- Ordered at low precedence. The library's chain is registered with
@Order(Ordered.LOWEST_PRECEDENCE - 5)— the same low precedence Spring Boot uses for its own default servlet security chain. This value is sourced fromSecurityFilterProperties.BASIC_AUTH_ORDER; the constant wasSecurityProperties.BASIC_AUTH_ORDERin Spring Boot 3.x and was relocated toSecurityFilterProperties.BASIC_AUTH_ORDERin Spring Boot 4.0 (stillOrdered.LOWEST_PRECEDENCE - 5). The library's chain has nosecurityMatcher, so it is the catch-all: any consumer-supplied chain with asecurityMatcherand a lower (higher-precedence)@Orderis consulted first by Spring Security'sFilterChainProxy, and unmatched requests fall through to the library's chain. - Backs off only on a same-named replacement. The library's chain bean is named
securityFilterChainand is annotated@ConditionalOnMissingBean(name = "securityFilterChain"). It backs off only when you define aSecurityFilterChainbean namedsecurityFilterChain(an explicit full replacement). Defining additional, differently-named chains does not suppress it.
Behavior change vs. earlier 4.x pre-releases: an earlier iteration used
@ConditionalOnMissingBean(SecurityFilterChain.class)(type-based), which suppressed the entire library chain as soon as you defined anySecurityFilterChain— even a narrow one (e.g. a test-API or actuator chain). That silently left the library's URIs unprotected. The conditional is now name-based so the standard Spring Security multi-chain@Orderlayering pattern works as expected.
This gives you two ways to customize security:
Option A — Add additional, narrower chains alongside the library's (recommended for most layering).
Define your own SecurityFilterChain with a securityMatcher scoping it to a subset of requests and a higher-precedence (lower) @Order. Give it any name other than securityFilterChain. Both chains coexist: your chain handles its matched requests, and the library's catch-all chain keeps protecting everything else (login, registration, password reset, profile, etc.).
@Configuration
@EnableWebSecurity
public class ApiSecurityConfig {
@Bean
@Order(1) // higher precedence than the library's catch-all chain
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // scopes this chain to /api/**
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.anyRequest().hasRole("ADMIN"))
.csrf(csrf -> csrf.disable());
return http.build(); // bean name is "apiSecurityFilterChain" -> library chain stays active
}
}Option B — Fully replace the library's chain (you own all the rules).
Define your own SecurityFilterChain bean named securityFilterChain. The library's chain backs off entirely and does not apply any of its rules; you are now responsible for protecting all URIs, including the framework's endpoints.
@Configuration
@EnableWebSecurity
public class CustomSecurityConfig {
@Bean // bean name MUST be "securityFilterChain" to replace the library's chain
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// You must also permit/secure the framework's own URIs here,
// since the library chain no longer applies:
.requestMatchers("/user/registration", "/user/login", "/user/resetPassword").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/user/login.html").permitAll());
return http.build();
}
}For most applications that only need to add a few rules, the simplest path is to rely on the library's chain and the user.security.* properties (protectedURIs, unprotectedURIs, defaultAction, etc.) rather than defining your own SecurityFilterChain at all.
If you extend UserService or implement custom user management:
- Method signatures unchanged - Core service methods remain compatible
- Password encoding - Still uses BCrypt, no changes required
- User entity - No schema changes required
To avoid holding a pooled DB connection during the deliberately slow bcrypt hash, password
hashing now runs outside the database transaction. As a result registerNewUserAccount,
changeUserPassword, and setInitialPassword are annotated Propagation.NOT_SUPPORTED and
delegate the actual DB write to short, separate transactions of their own.
Consumer-facing behavior change: these three methods no longer participate in a caller's
transaction. If you previously called one of them from inside your own @Transactional, that
outer transaction is now suspended for the call and the registration / password change commits
independently — an outer rollback will not roll back the registration or password change.
Most consumers call these methods from controllers (which are not transactional) and are unaffected. If you depend on enlisting these operations in a surrounding transaction, you will need to restructure that flow.
Verification and password-reset tokens are now hashed at rest. UserEmailService therefore takes
an additional TokenHasher constructor parameter. If you subclass UserEmailService, your
subclass constructor must accept and pass through the new parameter:
public CustomUserEmailService(
MailService mailService,
UserVerificationService userVerificationService,
PasswordResetTokenRepository passwordTokenRepository,
ApplicationEventPublisher eventPublisher,
SessionInvalidationService sessionInvalidationService,
TokenHasher tokenHasher) { // <-- new parameter
super(mailService, userVerificationService, passwordTokenRepository,
eventPublisher, sessionInvalidationService, tokenHasher);
}TokenHasher is a framework @Component, so it is available for injection. Consumers that do not
subclass UserEmailService are unaffected. The hashing is backward compatible at runtime: tokens
issued before the upgrade (stored in plaintext) are still resolved via a dual-read lookup and remain
usable until they expire.
A self-service password change (and removing a password to go passwordless) invalidates the user's other sessions but, by default, now preserves and regenerates the current session rather than logging the user out of the device they just used. This follows OWASP guidance (regenerate the current session id, invalidate the rest) and is a friendlier default.
To restore the previous "invalidate every session, including the current one" behavior, set:
user:
session:
invalidation:
keep-current-session-on-password-change: falseToken-based password resets (the forgot-password flow) are unaffected: there is no authenticated current session to preserve, so all of the user's sessions are invalidated as before.
If you have controllers that extend or work alongside framework controllers:
- DTOs - Update any code using
UserDtofor profile updates to useUserProfileUpdateDto - Validation - Bean validation works the same way
- Response format -
JSONResponseunchanged
Event handling remains unchanged:
OnRegistrationCompleteEventUserPreDeleteEventAuditEvent
All events fire as before with the same payload structures.
Issue: pattern must start with a /
This error occurs when URL patterns in security configuration don't start with /.
Solution: Review all entries in:
user.security.unprotectedURIsuser.security.protectedURIs- Any custom
requestMatchers()calls
Ensure every pattern starts with /.
Issue: ClassNotFoundException for test annotations
Spring Boot 4.0 moved test annotations to new packages.
Solution:
- Add the modular test dependencies (see Step 4)
- Update imports to new package locations
Issue: NoClassDefFoundError: com/fasterxml/jackson/...
Jackson 3 has different package structures.
Solution: Update ObjectMapper instantiation and check custom serializers.
Issue: Profile update returns validation error for password
The /user/updateUser endpoint now uses UserProfileUpdateDto.
Solution: Update your frontend to only send firstName and lastName fields.
Issue: user_credentials table not created on MariaDB/MySQL (WebAuthn)
With ddl-auto: update or create, Hibernate previously mapped the attestationObject and
attestationClientDataJson columns to VARBINARY(65535). Two such columns exceed MariaDB's
InnoDB 65,535-byte row-size limit, causing silent table creation failure. Symptoms include 500
errors on /user/auth-methods or /user/webauthn/credentials.
Solution (upgrading from a version prior to this fix):
If the user_credentials table was never created, it will be created automatically on next
startup with ddl-auto: update once you upgrade to this version.
If the table exists with VARBINARY columns (created on a non-MariaDB database), run:
ALTER TABLE user_credentials
MODIFY COLUMN public_key LONGBLOB NOT NULL,
MODIFY COLUMN attestation_object LONGBLOB,
MODIFY COLUMN attestation_client_data_json LONGBLOB;With ddl-auto: update, Hibernate will handle this automatically on MariaDB/MySQL. On
PostgreSQL no schema change is needed — the columns map to bytea in both old and new versions.
Issue: Java version incompatibility
Spring Boot 4.0 requires Java 21.
Solution:
- Update your JDK to 21+
- Update build configuration
- Update CI/CD pipelines
- Update deployment environments
| Framework Version | Spring Boot | Spring Security | Java | Status |
|---|---|---|---|---|
| 4.0.x | 4.0.x | 7.x | 21+ | Current |
| 3.5.x | 3.5.x | 6.x | 17+ | Maintained |
| 3.4.x | 3.4.x | 6.x | 17+ | Security fixes only |
| < 3.4 | < 3.4 | < 6 | 17+ | End of life |
For additional help, see:
- README - Main documentation
- Configuration Guide - All configuration options
- Demo Application - Working example
- GitHub Issues - Report problems