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

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)

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.