Skip to main content

From Fake IDs to Secure Passports: A Journey into JWT Middleware

·2075 words·10 mins
Morten Jensen
Author
Morten Jensen
Former chef with over 20 years in professional kitchens, now studying computer science
MiseOS Development - This article is part of a series.
Part 8: This Article

For the past several weeks, services identified the caller in a simple way: controllers passed a userId into service methods. The service then loaded the User entity and checked role rules (head chef, sous chef, line cook).

It worked, but it was not secure. Any client could fake that id, and it added unnecessary database reads.

JWT was this week’s theory in class, so this was the right moment to implement authentication properly.


Why I Built My Own Implementation
#

In class, we used an external library — dk.bugelhartmann.TokenSecurity — that wraps Nimbus JOSE+JWT. It works well for the general case, but it stores roles as a comma-joined string and returns a UserDTO from its own package.

My domain has a single UserRole enum per user — a cook holds one position in the kitchen hierarchy, not a collection of permissions. Storing that as a comma-joined string felt like fitting my model into the library’s assumptions rather than the other way around. Building directly on Nimbus meant the token structure could reflect the my actual domain.

The tradeoff is real though. A set of roles is the more flexible design — if a future requirement added a PURCHASING_MANAGER who needs head chef permissions on some endpoints but not others, a single role would not cover it cleanly. For MiseOS, the kitchen hierarchy is fixed and mutually exclusive by definition, so a single role is the right fit. But it is worth knowing what was traded away.

The other reason for building from scratch was understanding. JWT can be treated as a black box — call a library method, get a token back. I wanted to know what is actually in the token, how the signature works, and what happens when verification fails. Sixty lines in SecurityService and I understand every one of them. The exceptions it throws are mine, not generic library errors that map poorly to the rest of the exception hierarchy.


Password Hashing
#

Before authentication can work, passwords need to be stored safely. PasswordUtil wraps BCrypt implementations for hashing and verification:

public class PasswordUtil
{
   public static String hashPassword(String plainPassword, int cost)
    {
        return BCrypt.hashpw(plainPassword, BCrypt.gensalt(cost));
    }

    public static boolean verifyPassword(String plainText, String hashed)
    {
        return BCrypt.checkpw(plainText, hashed);
    }
}

BCrypt is intentionally slow — roughly 100ms per hash at the default cost factor. That is the feature, not a limitation. It is negligible for a legitimate login but makes brute-force attacks expensive. The salt is embedded in the hash output so there is nothing extra to store.

The hashedPassword field on the User entity has no @Getter. That is intentional — the hash can never accidentally appear in a serialized API response.

Password verification sits on the User entity as a domain method:

public boolean verifyPassword(String plainTextPassword)
{
    return PasswordUtil.verifyPassword(plainTextPassword, this.hashedPassword);
}

Password hashing flow
#

The registration process ensures that a plain-text password never leaves the service layer. The sequence below shows how the password is transformed before it reaches the database.

sequenceDiagram
    participant Client
    participant UserController
    participant UserService
    participant PasswordUtil
    participant UserDAO

    Client->>UserController: POST /register (email, password)
    activate UserController

    UserController->>UserService: registerUser(dto)
    activate UserService

    UserService->>PasswordUtil: hashPassword(plainText)
    PasswordUtil-->>UserService: hashedPassword

    UserService->>UserDAO: save(User with hashedPassword)
    UserDAO-->>UserService: persisted

    UserService-->>UserController: UserDTO
    deactivate UserService

    UserController-->>Client: 201 Created
    deactivate UserController

SecurityService — Login, Token Creation, Verification
#

Everything authentication-related lives in SecurityService. That was a deliberate SRP decision: the controller handles HTTP translation, the service handles identity logic. The controller has no knowledge of how tokens are built or verified.

Login
#

public LoginResponseDTO login(LoginRequestDTO dto)
{
    User user = userReader.findByEmail(dto.email())
        .orElseThrow(() -> new AuthenticationException("Invalid email or password"));

    if (!user.verifyPassword(dto.password()))
    {
        logger.warn("Login failed — wrong password for: {}", dto.email());
        throw new AuthenticationException("Invalid email or password");
    }

    String token = createToken(user.getId(), user.getEmail(), user.getUserRole().name());
    return new LoginResponseDTO(token, user.getEmail(), user.getUserRole().name());
}

Both “email not found” and “wrong password” return the same message. That is intentional — different messages leak information about which emails exist in the system.

Token Creation
#

public String createToken(Long userId, String email, String role)
{
    JWTClaimsSet claims = new JWTClaimsSet.Builder()
        .subject(email)
        .issuer(issuer)
        .claim("userId", userId)
        .claim("email",  email)
        .claim("role",   role)
        .expirationTime(new Date(System.currentTimeMillis() + expirationMs))
        .issueTime(new Date())
        .build();

    JWSObject jwsObject = new JWSObject(
        new JWSHeader(JWSAlgorithm.HS256),
        new Payload(claims.toJSONObject())
    );

    jwsObject.sign(new MACSigner(secretKey));
    return jwsObject.serialize();
}

The token is signed with HS256. The secret key never leaves the server. It is loaded from an environment variable and never committed to the repository. If someone modifies the userId claim to impersonate another user, signature verification fails immediately.

Three claims are included deliberately: userId because some service operation needs it and a database round trip to fetch it on every request would be wasteful, email for logging, and role because the authorization filter needs it on every request.

Verification
#

public AuthenticatedUser verifyAndExtract(String token)
{
    SignedJWT jwt = SignedJWT.parse(token);

    if (!jwt.verify(new MACVerifier(secretKey)))
        throw new AuthenticationException("Token signature invalid");

    JWTClaimsSet claims = getJwtClaimsSet(jwt); // expiry, notBefore, issuer

    UserRole userRole = parseUserRoleClaim(claims.getStringClaim("role"));

    return new AuthenticatedUser(
        claims.getLongClaim("userId"),
        claims.getStringClaim("email"),
        userRole
    );
}

getJwtClaimsSet checks three things: expiry, notBefore, and issuer. Here they are part of one operation, and if anything fails, the error message is specific.

Security config is also validated at startup. If secret/issuer is invalid (or secret is too short), the app fails fast.

if (secretKey.getBytes().length < 32)
    throw new IllegalStateException("JWT secret key too short. Use at least 32 bytes");

SecurityController — Two Responsibilities
#

The controller runs as middleware before every matched route, and also handles the login request.

public void authenticate(Context ctx)
{
    Set<String> allowedRoles = getAllowedRoles(ctx);

    if (allowedRoles.isEmpty() || allowedRoles.contains("ANYONE"))
        return;

    String header = ctx.header("Authorization");
    validateHeader(header);

    String token = header.substring(7);
    AuthenticatedUser authUser = securityService.verifyAndExtract(token);
    ctx.attribute("authUser", authUser);
}

public void authorize(Context ctx)
{
    Set<String> allowedRoles = getAllowedRoles(ctx);

    if (allowedRoles.isEmpty() || allowedRoles.contains("ANYONE"))
        return;

    AuthenticatedUser authUser = ctx.attribute("authUser");
    requireUserNotNull(authUser);

    if (allowedRoles.contains("KITCHEN_STAFF"))
    {
        if (!authUser.isKitchenStaff())
            throw new UnauthorizedActionException("Kitchen staff only");
        return;
    }

    if (!allowedRoles.contains(authUser.userRole().name()))
    {
        logger.warn("[{}] Authorization failed: {} has role {} but needs {}",
            ctx.attribute("request-id"), authUser.email(), authUser.userRole(), allowedRoles);
        throw new UnauthorizedActionException("Insufficient role. Required: " + allowedRoles);
    }
}

Both filters are registered in ServerConfig:

config.routes.beforeMatched(securityController::authenticate);
config.routes.beforeMatched(securityController::authorize);

beforeMatched only fires when a route matched. Using before instead would run the auth filter on 404 responses too — unnecessary noise and complexity.

Request lifecycle with authentication & authorization
#

flowchart TD
    User([Client / Browser]) --> Req[HTTP Request]
    Req --> Log[before: Request Logging]
    Log --> Auth{Authenticate}

    Auth -- "No Token / Invalid" --> R401[401 Unauthorized]
    Auth -- "Valid Token" --> Role{Authorize}

    Role -- "Insufficient Role" --> R403[403 Forbidden]
    Role -- "Role Allowed" --> Logic[Controller & Service]

    Logic --> DB[(Database)]
    DB --> Logic
    Logic --> R200[200/201 Success]

    R401 --> Resp[HTTP Response]
    R403 --> Resp
    R200 --> Resp
    Resp --> User

If the ‘Authenticate’ gate fails, the request dies immediately with a 401. If it passes, the token is ‘unpacked’ into an AuthenticatedUser object and stored in the Javalin context attributes. This object acts as a verified passport—it follows the request into the Controller and Service layers, allowing them to make logic decisions (like ‘Can this user see draft menus?’) without ever having to re-verify who the user is."


AuthenticatedUser — Replacing the userId Header
#

The old header was a Long that anyone could set to any value. The replacement is a record built from a verified, signed token:

public record AuthenticatedUser(Long userId, String email, UserRole userRole)
{
    public boolean isHeadChef() { return userRole == UserRole.HEAD_CHEF; }
    public boolean isSousChef() { return userRole == UserRole.SOUS_CHEF; }
    public boolean isLineCook() { return userRole == UserRole.LINE_COOK; }
    public boolean isKitchenStaff() { return isHeadChef() || isSousChef() || isLineCook(); }
}

authenticate() After token verification, authenticate() puts an AuthenticatedUser on the Javalin context. Controllers read it directly:

AuthenticatedUser authUser = SecurityUtil.getAuthenticatedUser(ctx);

And services that need a user, for business logic or user relation in other domains, gets its as a parameter:

public interface IDishSuggestionService
{
    DishSuggestionDTO createSuggestion(AuthenticatedUser authUser, DishSuggestionCreateDTO dto);

    DishSuggestionDTO approveSuggestion(AuthenticatedUser authUser, Long dishId);

    DishSuggestionDTO rejectSuggestion(AuthenticatedUser authUser, Long dishId, String feedback);
}

Refactoring the Service Layer
#

In the previous implementation, services often fetched User from DB just to check role. That was useful early on, but inefficient: one extra query purely for authorization.

With middleware-based authorization, route annotations now enforce coarse access before a request reaches the service. That removed many redundant role-check reads.

Example of efficient filtering using token identity:

Long creatorId = authUser.isHeadChef() || authUser.isSousChef()
    ? null
    : authUser.userId();

Ownership checks are now simple ID comparisons against already loaded resources:

boolean isCreator = suggestion.getCreatedBy().getId().equals(authUser.userId());
boolean isHeadChefOrSousChef = authUser.isHeadChef() || authUser.isSousChef();

if (!isCreator && !isHeadChefOrSousChef)
    throw new UnauthorizedActionException("You can only update your own suggestions");

When a full User entity is genuinely needed (e.g., createdBy, reviewer reference), DB lookup still happens — but now only where it adds domain value.


The Tricky Part — Same Endpoint, Different Responses
#

The most interesting design challenge was endpoints that multiple roles can reach but where the response differs by role. The weekly menu by-week endpoint is the clearest example:

get("/by-week", weeklyMenuController::getByWeekAndYear, Role.KITCHEN_STAFF, Role.ANYONE);

A guest (ANYONE) can only see published menus. A head chef can see drafts too. The route annotation cannot express that — it only controls access. The service handles the distinction:

private MenuStatus getMenuStatusPermission(AuthenticatedUser authUser)
{
    if (authUser == null)
        return MenuStatus.PUBLISHED; // guest — no token

    if (authUser.isHeadChef() || authUser.isSousChef())
        return null; // management — all statuses

    return MenuStatus.PUBLISHED; // line cook, customer
}

authUser is null when the endpoint is hit without a token — ANYONE routes skip the authenticate filter entirely. For these cases, there are two methods in SecurityUtil:

// Protected endpoints — throws if null
AuthenticatedUser authUser = SecurityUtil.getAuthenticatedUser(ctx);

// Public endpoints — returns null, let the service decide
AuthenticatedUser authUser = SecurityUtil.getOptionalAuthenticatedUser(ctx);

Updating the Tests
#

Every REST-assured test that previously used X-Dev-User-Id now goes through real login. A test utility class hits the actual login endpoint and returns a ready-to-use Bearer token:

headChefToken = TestAuthenticationUtil.bearerToken("gordon@kitchen.com", "Hash1");
lineCookToken  = TestAuthenticationUtil.bearerToken("claire@pastry.com",  "Hash2");

Tokens can now be used in tests like this:

        @Test
        @DisplayName("Head chef generates shopping list from approved requests")
        void generatesShoppingList()
        {
            LocalDate date = LocalDate.now().plusDays(7);
            String payload = getCreateListPayload(date);

            ShoppingListDTO response = given()
                .header("Authorization", headChefToken)
                .contentType(ContentType.JSON)
                .body(payload)

Global tokens gave an easy way to test protected endpoints. The tokens are generated on every test run, so they are always valid and reflect the current user data in the test database.

One adjustment in TestPopulator: BCrypt’s default cost factor makes test setup noticeably slow. With four users hashed before every test, the overhead is noticeable. Cost factor 4 is the minimum BCrypt allows and hashes in under 10ms — fine for test data that will never face a real attack:

User gordon = new User("Gordon", "Ramsay", "gordon@kitchen.com",
    PasswordUtil.hashPassword("Hash1", 4), UserRole.HEAD_CHEF);

Every protected endpoint now also has a @Nested Security class that includes:

  • missing token => 401
  • malformed/invalid token => 401
  • Token expired => 401.

What Worked Well
#

Separating SecurityService and SecurityController cleanly kept both focused. The controller reads headers and sets context attributes. The service builds and verifies tokens. Neither leaks into the other’s responsibility.

KITCHEN_STAFF as a convenience role in the Role enum was a good decision. Without it, every endpoint accessible to all three kitchen roles would need three annotations. With it, the common case is one word.


What Was Difficult
#

The hardest part was deciding where role enforcement belongs when a route is open to multiple roles but the response should differ by role. The route annotation cannot carry that information — it only knows whether to allow or deny. So AuthenticatedUser has to be passed into service methods that would otherwise not need it, purely to inform the filtering logic.

The ANYONE case added another layer: a null authUser is valid for public endpoints and must be handled explicitly in the service rather than treated as an error.


What I Would Do Differently
#

One thing worth understanding but that I did not have time to implement is refresh tokens. The current setup issues a single token with a fixed expiry — when it expires, the user must log in again. For a kitchen tool used during active service that could be a real usability problem.

It is on the list to implement — both because it brings real value to the UI and because it is something worth understanding properly.


Next Steps
#

With HTTP authentication and role-based authorization now in place, the next step is operational hardening:

  • CI/CD with GitHub Actions
  • deployment environment setup

This is part 8 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.

MiseOS Development - This article is part of a series.
Part 8: This Article