Requests in my starter kit started failing with "User not found", or a 403, a second after the same request had worked. Refresh, and it worked again. The dashboard sometimes didn't load at all. No pattern by user, browser or endpoint.
The cause was one ThreadLocal set in the wrong place. If you use STOMP over WebSockets with Spring and keep anything request-scoped in a ThreadLocal, this one is worth five minutes.
Fixed in Arab Enterprise Kit 5.3.1, a free update for every v5.x license.
The setup
The kit is multi-tenant. The current tenant lives in a ThreadLocal:
public final class TenantContext { private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenantId() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } } An HTTP filter sets it from the JWT, and an aspect turns on a Hibernate filter for that tenant before every service call. User lookups go through that filter too.
Live notifications use STOMP over SockJS. A ChannelInterceptor authenticates the CONNECT frame with the same JWT.
The bug
The old interceptor did this on CONNECT:
SecurityContextHolder.getContext().setAuthentication(authentication); // Initialize tenant for this handshake using the JWT claim. if (tenantId != null && !tenantId.isBlank()) { TenantContext.setTenantId(tenantId); } and cleared the tenant on DISCONNECT.
The problem is which thread runs that code. ChannelInterceptor.preSend runs on whichever thread sent the frame. With SockJS, that can be a pooled Tomcat worker, the same pool that serves your HTTP requests. The CONNECT set the tenant and the security context on that worker and left them there until DISCONNECT, which may arrive on a different thread or never.
The next HTTP request that landed on that worker started with someone else's tenant already set. Its user lookup was filtered to the wrong tenant, the user "didn't exist", and the request failed with "User not found" or a 403. Which request got the poisoned thread was down to the pool, so it looked random.
The fix
Two changes, each enough on its own, both kept:
1. Never put request-scoped state in thread-locals from preSend. The interceptor now keeps the user and the tenant on the WebSocket session:
accessor.setUser(authentication); String tenantId = jwtUtils.getClaimFromToken(token, "tenantId"); Map<String, Object> session = accessor.getSessionAttributes(); if (session != null && tenantId != null && !tenantId.isBlank()) { session.put(TENANT_ATTRIBUTE, tenantId); } 2. Every HTTP request starts clean. The JWT filter clears the tenant before it does anything else:
// Pooled threads are reused: never let a tenant left by an earlier task scope this request TenantContext.clear(); The second change is the safety net. Even if some future code leaks a tenant onto a worker thread, the next request can't inherit it.
There's a test for the first change too: after an authenticated CONNECT, TenantContext.getTenantId() on the sending thread must still be empty, and the tenant must be on the session.
How to find this kind of bug
- Look for every
ThreadLocal.setand ask: on which thread does this run, and where is the matchingremove? If you can't point at the line that clears it on the same thread, it leaks. - Be suspicious of interceptors, listeners and callbacks. They often run on threads you don't own.
- Clear request-scoped thread-locals at the start of each request as well as at the end.
- Symptoms that appear "randomly" and disappear on retry often mean shared state on pooled threads.
Talk to me
This is part of a series about building Arab Enterprise Kit, an Arabic-first, multi-tenant SaaS foundation in Java 25, Spring Boot 4 and Angular 21. There's a free Lite edition on GitHub, and the full kit is on Gumroad. I build SaaS products and take freelance full-stack and backend projects: mahmoud-farouk-portfolio.vercel.app.
What's the strangest ThreadLocal bug you've chased?