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:
- Proactive sync on signup - But what about social logins? They don’t trigger signup hooks reliably.
- Batch sync using Auth0 Management API - Creates complexity, potential for stale data, and rate limiting issues.
- 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 | vSpring Security Filter Chain | vJWT Token Validation (Auth0 public keys) | vExtract 'sub' claim from JWT | vCheck local database for user | +-- User exists --> Continue request | +-- User missing --> Create user --> Continue requestThis 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:
@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:
@Service@Transactionalpublic 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:
@Servicepublic 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); }}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:
@Configuration@EnableWebSecuritypublic 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:
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: falseCommon 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:
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:
- Admin dashboards need to show all users before they’ve logged in
- Bulk operations need to run against all users
- Analytics require a complete user list
For these cases, implement a background job using the Auth0 Management API:
@Servicepublic 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:
@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:
@SpringBootTestclass UserSynchronizationServiceTest {
@Autowired private UserSynchronizationService syncService;
@Autowired private UserRepository userRepository;
@Test void shouldCreateNewUserFromJwt() { Jwt jwt = Jwt.withTokenValue("test") .subject("auth0|123456") .claim("name", "Test User") .build();
User user = syncService.syncUserFromJwt(jwt);
assertThat(user.getAuth0Id()).isEqualTo("auth0|123456"); assertThat(user.getName()).isEqualTo("Test User"); }
@Test void shouldReturnExistingUser() { // Create user first User existing = new User(); existing.setAuth0Id("auth0|123456"); userRepository.save(existing);
Jwt jwt = Jwt.withTokenValue("test") .subject("auth0|123456") .build();
User user = syncService.syncUserFromJwt(jwt);
// Should return existing, not create new }}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:
- 👨💻 Auth0 Management API Documentation
- 👨💻 Spring Security OAuth2 Resource Server
- 👨💻 Auth0 Spring Boot Integration
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments