DEV Community

Cover image for From Email to User ID: A Security-Driven Refactor in Spring Boot Backend - Finovara
Marcin Parśniak
Marcin Parśniak

Posted on

From Email to User ID: A Security-Driven Refactor in Spring Boot Backend - Finovara

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)
Enter fullscreen mode Exit fullscreen mode

And in JWT:

.setSubject(user.getEmail())
Enter fullscreen mode Exit fullscreen mode

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 userId as 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())
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

This handles multiple formats and keeps backward compatibility with older tokens.


3. Authentication filter now works with ID

Before:

findByEmail(...)
Enter fullscreen mode Exit fullscreen mode

After:

Long userId = jwtService.extractUserId(jwt);
User user = userRepository.findById(userId).orElse(null);
Enter fullscreen mode Exit fullscreen mode

This removes ambiguity and avoids relying on user-controlled fields.


4. CustomUserDetails updated

private final Long id;
private final String email;
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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 findByEmail across 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!

GitHub logo M4rc1nek / finovara-backend

Backend service for a personal finance management application

Finovara

Backend platform for a personal finance management application, built with Java 25 and Spring Boot 4 as a set of microservices.

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, shared accounts and financial reporting.

The platform is built as a set of independent services, each responsible for a separate area of the domain, so that individual parts of the system can be developed, deployed and scaled on their own. Security is a central concern: authentication is based on JWT, sessions are tracked per device, and user activity is recorded in an audit log.


Key Features

Accounts and Security

  • Authentication and Authorization - stateless JWT security built on Spring Security, with an access and refresh token flow.
  • Device and Session Tracking…




Top comments (4)

Collapse
 
buildbasekit profile image
BuildBaseKit •

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.

Collapse
 
m4rc1nek profile image
Marcin Parśniak •

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

Collapse
 
buildbasekit profile image
BuildBaseKit •

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?

Thread Thread
 
m4rc1nek profile image
Marcin Parśniak •

Of course I standardized it.