Skip to content

How to Sync Auth0 Users with a Local Database in Spring Boot?

When I first integrated Auth0 with a Spring Boot application, I struggled with a seemingly simple question: should I proactively sync all Auth0 users to my local database, or is there a better approach?

After implementing this pattern in production and hitting several gotchas, I found that a just-in-time (JIT) synchronization pattern works best for most applications. Here’s what I learned.

The Problem

You’re using Auth0 for authentication (great choice for offloading security concerns), but your application still needs to store user-related data in your local database—things like user preferences, application-specific roles, or relationships to other entities.

The question is: how do you keep user data synchronized between Auth0 (the source of truth for authentication) and your local database?

Why Just-in-Time Sync?

I considered several approaches:

  1. Proactive sync on signup - But what about social logins? They don’t trigger signup hooks reliably.
  2. Batch sync using Auth0 Management API - Creates complexity, potential for stale data, and rate limiting issues.
  3. Calling Management API on every request - Terrible idea. Each API call takes 200-500ms and you’ll hit rate limits quickly.

JIT synchronization solves these problems by creating users on-demand when they first access your application. Auth0 remains the single source of truth for authentication, while your local database only stores what it needs to store.

The JIT Synchronization Flow

User Request
|
v
Spring Security Filter Chain
|
v
JWT Token Validation (Auth0 public keys)
|
v
Extract 'sub' claim from JWT
|
v
Check local database for user
|
+-- User exists --> Continue request
|
+-- User missing --> Create user --> Continue request

This flow happens on every authenticated request, but the database check is fast, and user creation only happens once per user.

Implementation

User Entity

First, I created a user entity that stores the Auth0 identifier along with application-specific fields:

User.java
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_auth0_id", columnList = "auth0_id", unique = true),
@Index(name = "idx_email", columnList = "email")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "auth0_id", nullable = false, unique = true)
private String auth0Id;
@Column(nullable = false)
private String email;
private String name;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private Role role = Role.USER;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// Getters and setters
}

The auth0Id field stores the sub claim from the JWT token—this is Auth0’s unique identifier for the user.

Synchronization Service

Next, I created a service to handle the synchronization logic:

UserSynchronizationService.java
@Service
@Transactional
public class UserSynchronizationService {
private static final Logger logger = LoggerFactory.getLogger(UserSynchronizationService.class);
private final UserRepository userRepository;
public UserSynchronizationService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User syncUserFromJwt(Jwt jwt) {
String auth0Id = jwt.getSubject();
return userRepository.findByAuth0Id(auth0Id)
.orElseGet(() -> createNewUser(jwt));
}
private User createNewUser(Jwt jwt) {
User user = new User();
user.setAuth0Id(jwt.getSubject());
user.setEmail(jwt.getClaim("email"));
user.setName(extractName(jwt));
try {
User savedUser = userRepository.save(user);
logger.info("Created new user with auth0Id: {}", auth0Id);
return savedUser;
} catch (DataIntegrityViolationException e) {
// Race condition: another request created the user
logger.warn("Race condition detected for auth0Id: {}, fetching existing user", auth0Id);
return userRepository.findByAuth0Id(auth0Id)
.orElseThrow(() -> new IllegalStateException("User should exist after race condition"));
}
}
private String extractName(Jwt jwt) {
String name = jwt.getClaim("name");
if (name != null) {
return name;
}
// Fallback to email prefix
String email = jwt.getClaim("email");
return email != null ? email.split("@")[0] : "Unknown";
}
}

The key insight here is handling race conditions. If two requests arrive simultaneously for a new user, both might try to create the user, resulting in a DataIntegrityViolationException. I catch this and fetch the existing user instead.

Integrating with Spring Security

I integrated the synchronization with Spring Security’s authentication flow by creating a custom UserDetailsService:

CustomUserDetailsService.java
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final UserSynchronizationService syncService;
public CustomUserDetailsService(
UserRepository userRepository,
UserSynchronizationService syncService) {
this.userRepository = userRepository;
this.syncService = syncService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// This method is for form-based auth (not used with JWT)
throw new UnsupportedOperationException("Use JWT authentication");
}
public UserDetails loadUserByJwt(Jwt jwt) {
User user = syncService.syncUserFromJwt(jwt);
return new CustomUserDetails(user, jwt);
}
}
CustomUserDetails.java
public class CustomUserDetails implements UserDetails, OAuth2User {
private final User user;
private final Jwt jwt;
private final Map<String, Object> attributes;
public CustomUserDetails(User user, Jwt jwt) {
this.user = user;
this.jwt = jwt;
this.attributes = jwt.getClaims();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public String getPassword() {
return null; // Not used with JWT
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + user.getRole().name())
);
}
// Other UserDetails and OAuth2User methods...
public User getUser() {
return user;
}
public String getAuth0Id() {
return user.getAuth0Id();
}
}

Security Configuration

The security configuration connects everything together:

SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
public SecurityConfig(CustomUserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new JwtGrantedAuthoritiesConverter());
converter.setPrincipalClaimName("sub");
// Custom converter that syncs user
return new Converter<Jwt, AbstractAuthenticationToken>() {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
UserDetails userDetails = userDetailsService.loadUserByJwt(jwt);
return new UsernamePasswordAuthenticationToken(
userDetails,
jwt,
userDetails.getAuthorities()
);
}
};
}
}

Application Configuration

Finally, I configured the application to validate JWTs against Auth0:

application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://your-tenant.auth0.com/
audiences:
- your-api-identifier
datasource:
url: jdbc:postgresql://localhost:5432/myapp
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false

Common Mistakes I Made

Mistake 1: Calling Management API on Every Request

My first attempt called the Auth0 Management API to fetch user details on every request. This was slow (200-500ms per request) and quickly hit rate limits.

Fix: Use JWT claims directly. The JWT already contains the user’s email and name for most auth flows.

Mistake 2: Not Handling Race Conditions

When a new user made multiple simultaneous requests, I saw duplicate key errors.

Fix: Catch DataIntegrityViolationException and query for the existing user.

Mistake 3: Stale User Data

Users change their names or emails in Auth0, but my local database showed old values.

Fix: Update user properties on each sync:

UserSynchronizationService.java
public User syncUserFromJwt(Jwt jwt) {
String auth0Id = jwt.getSubject();
return userRepository.findByAuth0Id(auth0Id)
.map(user -> updateUserInfo(user, jwt))
.orElseGet(() -> createNewUser(jwt));
}
private User updateUserInfo(User user, Jwt jwt) {
user.setEmail(jwt.getClaim("email"));
user.setName(extractName(jwt));
return userRepository.save(user);
}

Be careful here—only update fields that should sync from Auth0. Application-specific fields like roles should typically be managed locally.

When to Use Proactive Sync Instead

JIT sync works well for most applications, but proactive sync makes sense when:

  1. Admin dashboards need to show all users before they’ve logged in
  2. Bulk operations need to run against all users
  3. Analytics require a complete user list

For these cases, implement a background job using the Auth0 Management API:

UserBatchSyncService.java
@Service
public class UserBatchSyncService {
private final Auth0ManagementService auth0Service;
private final UserRepository userRepository;
public void syncAllUsers() {
List<User> auth0Users = auth0Service.fetchAllUsers();
for (User auth0User : auth0Users) {
userRepository.findByAuth0Id(auth0User.getAuth0Id())
.ifPresentOrElse(
existing -> updateIfNeeded(existing, auth0User),
() -> userRepository.save(auth0User)
);
}
}
}

Run this as a scheduled job, but keep JIT sync as the fallback.

Performance Considerations

The JIT sync adds minimal overhead:

  • Database lookup by indexed column: ~1-5ms
  • User creation (rare): ~5-15ms
  • Most requests just hit the cache and continue

If performance becomes a concern, add a short-lived cache:

UserSynchronizationService.java
@Cacheable(value = "users", key = "#jwt.subject")
public User syncUserFromJwt(Jwt jwt) {
// ...
}

Just remember to invalidate the cache when user data changes.

Testing the Implementation

I wrote tests to verify the sync behavior:

UserSynchronizationServiceTest.java
@SpringBootTest
class UserSynchronizationServiceTest {
@Autowired
private UserSynchronizationService syncService;
@Autowired
private UserRepository userRepository;
@Test
void shouldCreateNewUserFromJwt() {
Jwt jwt = Jwt.withTokenValue("test")
.subject("auth0|123456")
.claim("email", "[email protected]")
.claim("name", "Test User")
.build();
User user = syncService.syncUserFromJwt(jwt);
assertThat(user.getAuth0Id()).isEqualTo("auth0|123456");
assertThat(user.getEmail()).isEqualTo("[email protected]");
assertThat(user.getName()).isEqualTo("Test User");
}
@Test
void shouldReturnExistingUser() {
// Create user first
User existing = new User();
existing.setAuth0Id("auth0|123456");
existing.setEmail("[email protected]");
userRepository.save(existing);
Jwt jwt = Jwt.withTokenValue("test")
.subject("auth0|123456")
.claim("email", "[email protected]")
.build();
User user = syncService.syncUserFromJwt(jwt);
// Should return existing, not create new
assertThat(user.getEmail()).isEqualTo("[email protected]");
}
}

Summary

JIT synchronization is the recommended pattern for keeping Auth0 users in sync with your local Spring Boot database. It’s simple, efficient, and avoids the pitfalls of proactive synchronization.

The key benefits:

  • No proactive sync complexity - users are created when they first access your app
  • No orphaned records - every local user corresponds to a real Auth0 user
  • Works for all auth methods - social logins, email/password, passwordless
  • Minimal performance impact - just a quick database lookup per request

For most applications, this pattern is all you need. Add proactive sync only if you have specific requirements like admin dashboards or bulk operations.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments