Should You Maintain a Local User Table When Using Auth0?
Should You Maintain a Local User Table When Using Auth0?
When I first integrated Auth0 into my Spring Boot application, I asked myself: “Do I really need a local user table if Auth0 handles all the authentication?” This is a common question that trips up many developers new to OAuth2 and identity providers.
The short answer: Yes, you should maintain a local user table when using Auth0.
But the real question isn’t if you need one—it’s why you need one and how to design it correctly.
The Problem: Auth0 Alone Isn’t Enough
Auth0 is fantastic at what it does: authentication. It handles:
- User credentials (email, password)
- Password hashing and security
- Social logins (Google, GitHub, Facebook)
- Multi-factor authentication (MFA)
- Session management
- JWT token generation
However, Auth0 is not designed to manage your application’s specific data. It doesn’t know about:
- User roles in your system (admin, moderator, regular user)
- User preferences (theme, notifications)
- Relationships (which users belong to which teams)
- Business logic (subscription tier, feature flags)
- Application-specific metadata
When you try to shoehorn all this into Auth0’s user metadata, you’re fighting the tool’s design.
Separation of Concerns
Here’s how I think about the division of responsibilities:
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐│ Auth0 (AuthN) │ │ Local DB (Application) │├─────────────────────────────────────┤ ├─────────────────────────────────────┤│ ✓ Email & password │ │ ✓ Application roles ││ ✓ Social logins │ │ ✓ User preferences ││ ✓ MFA setup │ │ ✓ Business relationships ││ ✓ Session tokens │ │ ✓ Feature flags ││ ✓ User metadata (basic) │ │ ✓ Audit logs ││ │ │ ✓ Application state ││ source of truth: identity │ │ source of truth: business data │└─────────────────────────────────────┘ └─────────────────────────────────────┘ │ │ │ │ └─────────────┬─────────────────────────────┘ │ linked by: auth0_id (sub claim)The key insight: Auth0 owns identity, your database owns application context.
The Anti-Patterns to Avoid
Before I show you the right way, let me highlight three mistakes I’ve seen:
1. Duplicating Passwords in Local DB
Never store passwords locally when using Auth0. Auth0 handles credential management—you should never see or store user passwords. This defeats the purpose of using an identity provider.
2. Using Email as Primary Key
Emails can change. Users update their email addresses. Auth0’s sub (subject) claim is guaranteed to be unique and immutable. Use it.
3. Calling Auth0 Management API on Every Request
I’ve seen developers query Auth0’s Management API to get user details on every request. This adds latency, consumes API rate limits, and makes your application dependent on Auth0’s availability. Cache what you need locally.
Implementation: Spring Boot + Auth0
Let me show you how I implement this pattern in Spring Boot.
SQL Schema for Local Users
First, create a local users table that references Auth0:
CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, auth0_id VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) NOT NULL, name VARCHAR(255), role VARCHAR(50) NOT NULL DEFAULT 'USER', preferences JSONB DEFAULT '{}', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX idx_users_auth0_id ON users(auth0_id);CREATE INDEX idx_users_email ON users(email);Notice:
auth0_idstores thesubclaim from JWT (unique identifier)emailis stored for convenience but not used as primary keyroleis application-specific (Auth0 doesn’t know about this)preferencesuses JSONB for flexibility (PostgreSQL feature)
JPA Entity
Map this to a JPA entity:
package com.example.app.entity;
import jakarta.persistence.*;import java.time.Instant;import java.util.HashMap;import java.util.Map;
@Entity@Table(name = "users")public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(name = "auth0_id", unique = true, nullable = false) private String auth0Id;
@Column(nullable = false) private String email;
private String name;
@Column(nullable = false) @Enumerated(EnumType.STRING) private Role role = Role.USER;
@Column(columnDefinition = "jsonb") @ElementCollection private Map<String, Object> preferences = new HashMap<>();
@Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt;
@Column(name = "updated_at", nullable = false) private Instant updatedAt;
@PrePersist protected void onCreate() { createdAt = Instant.now(); updatedAt = Instant.now(); }
@PreUpdate protected void onUpdate() { updatedAt = Instant.now(); }
// Getters and setters... public Long getId() { return id; } public String getAuth0Id() { return auth0Id; } public void setAuth0Id(String auth0Id) { this.auth0Id = auth0Id; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Role getRole() { return role; } public void setRole(Role role) { this.role = role; } public Map<String, Object> getPreferences() { return preferences; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; }}package com.example.app.entity;
public enum Role { USER, MODERATOR, ADMIN}Repository with Custom Queries
Create a repository with a method to find by Auth0 ID:
package com.example.app.repository;
import com.example.app.entity.User;import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repositorypublic interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByAuth0Id(String auth0Id);
Optional<User> findByEmail(String email);
boolean existsByAuth0Id(String auth0Id);}The Key Pattern: Just-in-Time User Creation
Here’s the critical piece—the “get or create” pattern that creates local users on first authentication:
package com.example.app.service;
import com.example.app.entity.User;import com.example.app.repository.UserRepository;import org.springframework.security.oauth2.jwt.Jwt;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;
@Servicepublic class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) { this.userRepository = userRepository; }
@Transactional public User getOrCreateUser(Jwt jwt) { String auth0Id = jwt.getSubject(); // The 'sub' claim
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(jwt.getClaim("name"));
// Set default preferences user.getPreferences().put("theme", "light"); user.getPreferences().put("notifications", true);
return userRepository.save(user); }
public Optional<User> findByAuth0Id(String auth0Id) { return userRepository.findByAuth0Id(auth0Id); }
public User save(User user) { return userRepository.save(user); }}This pattern is beautiful because:
- No separate registration flow needed
- Users are created automatically on first login
- Subsequent logins fetch the existing user
- All application-specific data lives in your database
Security Configuration
Configure Spring Security to validate JWTs from Auth0:
package com.example.app.config;
import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.web.SecurityFilterChain;import org.springframework.web.cors.CorsConfiguration;import org.springframework.web.cors.CorsConfigurationSource;import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration@EnableWebSecuritypublic class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt());
return http.build(); }
@Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE")); configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; }}Configure your application.yml:
spring: security: oauth2: resourceserver: jwt: issuer-uri: https://your-tenant.auth0.com/ audiences: your-api-audienceController: Accessing the Current User
Now create controllers that extract the user from the JWT:
package com.example.app.controller;
import com.example.app.entity.User;import com.example.app.service.UserService;import org.springframework.http.ResponseEntity;import org.springframework.security.core.annotation.AuthenticationPrincipal;import org.springframework.security.oauth2.jwt.Jwt;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/api/users")public class UserController {
private final UserService userService;
public UserController(UserService userService) { this.userService = userService; }
@GetMapping("/me") public ResponseEntity<UserProfile> getCurrentUser(@AuthenticationPrincipal Jwt jwt) { User user = userService.getOrCreateUser(jwt);
UserProfile profile = new UserProfile( user.getId(), user.getEmail(), user.getName(), user.getRole().name(), user.getPreferences() );
return ResponseEntity.ok(profile); }
@PutMapping("/me/preferences") public ResponseEntity<User> updatePreferences( @AuthenticationPrincipal Jwt jwt, @RequestBody Map<String, Object> preferences ) { User user = userService.getOrCreateUser(jwt); user.getPreferences().putAll(preferences); User updated = userService.save(user);
return ResponseEntity.ok(updated); }
public record UserProfile( Long id, String email, String name, String role, Map<String, Object> preferences ) {}}Authorization: Using Roles
With the local user table, you can implement application-level authorization. Here’s an aspect that checks roles:
package com.example.app.security;
import com.example.app.entity.User;import com.example.app.service.UserService;import org.springframework.security.core.Authentication;import org.springframework.security.oauth2.jwt.Jwt;import org.springframework.stereotype.Component;
@Component("userSecurity")public class UserSecurity {
private final UserService userService;
public UserSecurity(UserService userService) { this.userService = userService; }
public boolean hasRole(Authentication authentication, String role) { if (authentication.getPrincipal() instanceof Jwt jwt) { User user = userService.getOrCreateUser(jwt); return user.getRole().name().equals(role); } return false; }
public boolean isOwner(Authentication authentication, Long userId) { if (authentication.getPrincipal() instanceof Jwt jwt) { User user = userService.getOrCreateUser(jwt); return user.getId().equals(userId); } return false; }}Use it in your controllers:
package com.example.app.controller;
import org.springframework.security.access.prepost.PreAuthorize;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/api/documents")public class DocumentController {
@GetMapping("/{id}") @PreAuthorize("@userSecurity.isOwner(authentication, #id) or @userSecurity.hasRole(authentication, 'ADMIN')") public Document getDocument(@PathVariable Long id) { // ... }
@DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") public void deleteDocument(@PathVariable Long id) { // ... }}When to Sync with Auth0?
Sometimes you need data from Auth0. Here’s when I sync:
- On first login (create local user with JWT claims)
- When user updates profile (sync back to Auth0 via Management API, optional)
- Never on every request (this would be wasteful)
If you need to sync user data back to Auth0, use the Management API:
package com.example.app.service;
import com.auth0.client.mgmt.ManagementAPI;import com.auth0.client.mgmt.filter.UserFilter;import com.auth0.json.mgmt.users.User;import org.springframework.stereotype.Service;
@Servicepublic class Auth0ManagementService {
private final ManagementAPI managementAPI;
public Auth0ManagementService(ManagementAPI managementAPI) { this.managementAPI = managementAPI; }
public void updateUserInAuth0(String auth0Id, String name, String email) { try { User userUpdate = new User(); userUpdate.setName(name); userUpdate.setEmail(email);
managementAPI.users().update(auth0Id, userUpdate, new UserFilter()).execute(); } catch (Exception e) { // Log and handle error throw new RuntimeException("Failed to update user in Auth0", e); } }}Best Practices Summary
Here’s what I’ve learned:
- Use
subclaim as unique identifier - Never use email; it can change - Just-in-time user creation - Create local users on first authentication
- Keep application data local - Roles, preferences, relationships stay in your DB
- Minimize Management API calls - Only sync when necessary
- Cache user data locally - Don’t call Auth0 on every request
- Handle edge cases - What if Auth0 user is deleted? Add cleanup jobs.
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