At some point, a backend outgrows its initial assumptions.
In my case, that assumption was simple:
email = user identity
It worked fine early on. It was convenient, readable, and easy to query. But over time, it started to create real limitations—both in terms of features and security.
This post is about why I moved away from using email as the primary identifier, what had to change, and what improved as a result.
The Problem with Using Email as Identity
Initially, email was used everywhere:
- in JWT subject
- in database queries
- across service layer logic
Typical example:
userRepository.findByEmail(email)
And in JWT:
.setSubject(user.getEmail())
This approach has one fundamental flaw:
Email is not a stable identifier.
The moment you want to support changing email, things start to break:
- JWT tokens become inconsistent
- existing sessions may become invalid
- references across the system lose integrity
In practice, it blocks you from implementing a very basic feature: updating user email.
Why This Matters
Using a mutable field as identity introduces a few issues:
1. Tight coupling
Your entire system depends on a value that can change.
2. Inconsistent authentication
JWT subject no longer represents a stable user reference.
3. Limited extensibility
Features like:
- email change
- social login
- account linking become harder to implement cleanly
The Refactor: Switching to User ID
The core idea was straightforward:
Use
userIdas the single source of identity across the system.
Email is now just a property of the user—not something the system relies on for identification.
Key Changes
1. JWT now stores user ID
.setSubject(user.getId().toString())
.claim("userId", user.getId())
This ensures:
- the identifier is stable
- it maps directly to the database
- it doesn’t change over time
2. Extracting user ID from token
public Long extractUserId(String token) {
Claims claims = extractAllClaims(token);
Object userIdClaim = claims.get("userId");
if (userIdClaim instanceof Number number) {
return number.longValue();
}
if (userIdClaim instanceof String userIdText) {
return Long.parseLong(userIdText);
}
return Long.parseLong(claims.getSubject());
}
This handles multiple formats and keeps backward compatibility with older tokens.
3. Authentication filter now works with ID
Before:
findByEmail(...)
After:
Long userId = jwtService.extractUserId(jwt);
User user = userRepository.findById(userId).orElse(null);
This removes ambiguity and avoids relying on user-controlled fields.
4. CustomUserDetails updated
private final Long id;
private final String email;
The important part is that ID is now always available in the security context.
5. Centralized access via SecurityUtils
public static Long getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new IllegalStateException("No authenticated user in security context");
}
Object principal = authentication.getPrincipal();
if (principal instanceof CustomUserDetails userDetails) {
return userDetails.getId();
}
if (principal instanceof String principalValue) {
return Long.parseLong(principalValue);
}
throw new IllegalStateException("Unsupported principal type");
}
This becomes the standard way to access the current user across the application.
What Improved
More predictable authentication
User identity is now:
- immutable
- consistent across requests
- independent of user input
Better security model
The system no longer relies on fields that users can change.
Every request resolves to a database ID, which must exist.
Easier feature development
Adding support for:
- email change
- multiple authentication methods
- external providers
is now straightforward.
What Was Costly
This wasn’t a small refactor.
It required:
- updating JWT generation and parsing
- rewriting authentication filter logic
- replacing
findByEmailacross the codebase - adjusting services and flows that depended on email
It’s the kind of change that touches a lot of code and needs to be done consistently.
Things to Watch Out For
If you’re considering a similar change:
Old tokens
Tokens using email may stop working. You may need to force re-authentication.
Partial refactors
Mixing email-based and ID-based logic will lead to subtle bugs.
Tests
Without proper test coverage, it’s easy to introduce regressions in authentication flow.
Thanks for reading!
If you'd like to see Finovara development, check out my GitHub!
M4rc1nek
/
finovara-backend
Backend service for a personal finance management application
💰 Finovara — Backend
Backend REST API for a personal finance management application built with Java 25 and Spring Boot 4.
📖 About the Project
Finovara is a personal finance platform designed to help users take full control of their money. The backend exposes a secure REST API that powers tracking of income and expenses, budget management, savings goals, and financial reporting — all wrapped in a bank-grade security model based on JWT authentication.
The application is designed with scalability in mind and is fully containerized via Docker, with separate production and test database environments managed through Docker Compose.
🎯 Key Features
- 🔐 Authentication & Authorization — JWT-based stateless security with Spring Security; access and refresh token flow with device/user-agent detection
- 💸 Income & Expense Tracking — full CRUD for financial operations with category tagging
- 📊 Statistics & Reports — aggregated financial summaries, spending trends, and exportable PDF reports
- 🏦…
Top comments (4)
This is one of those changes every backend ends up making sooner or later.
Starts simple with email, then breaks the moment you need flexibility.
Feels like a pattern that shouldn’t be rewritten in every project.
Curious if you think this belongs as part of a reusable backend base instead of a one-off refactor each time.
I made a mistake at first, I just didn't think about it.. but this experience taught me that I won't do it again
Yeah makes sense, most people only realize it after hitting that wall.
Feels like this exact mistake keeps repeating across projects though.
I’m thinking this kind of pattern (ID-based auth, token handling, etc.) should just come pre-solved in a base setup instead of everyone relearning it.
Did you end up standardizing it in your newer projects?
Of course I standardized it.