Skip to main content
N'doua Adé Kouadio MOLOU
All writing
Field report3 min read

Making an application multi-tenant when it never was

55 entities written for a single site, and a second branch to open. How isolation was added after the fact — and the 21,903-byte token that nearly stopped everything.

A multi-branch management platform, in production, and a second branch to open. The client's name adds nothing here; the problem does: 55 JPA entities that had never had to ask who a row belonged to.

Three ways to separate data

One database per client, one schema per client, or a discriminating column.

The first two isolate better and cost more to run: as many migrations, backups and connections as there are clients. The third costs almost nothing to run, and puts the whole burden of isolation on the code — one forgotten where, and a client reads another client's data.

The third was chosen, on one condition: that the where could not be forgotten.

The request context, and its trap

The domain being called identifies the branch. A filter reads the Host header, finds the branch in the database, and drops its identifier into a ThreadLocal.

ThreadLocal is the right tool and the right trap. It makes the identifier visible throughout request handling without passing it from method to method. But the thread that handled the request goes back to the pool, and whatever is left in it serves the next request.

Hence the one line in the whole design that is not negotiable:

finally { TenantContext.clear(); }

Without it the leak is not systematic. It is intermittent — and therefore impossible to find.

The token must say the same thing as the domain

The branch identifier also goes into the token at sign-in. So there are two sources: the domain being called, and the token being presented.

They must agree. A token issued for branch A, replayed on branch B's domain, is refused before a single query reaches the database. Without that check, changing domain would be enough to change client.

55 entities, one superclass

The where that cannot be forgotten is placed by Hibernate: a filter declared on a mapped superclass that all 55 entities inherit from, switched on for every repository call by an aspect.

The point is not to save 55 annotations. The point is that the 56th entity, written six months from now, inherits the filter without its author thinking about it. Security by default beats security by discipline — because discipline wears out, and defaults do not.

The 21,903-byte token

The migration was finished and working. While preparing the production rollout, one detail nearly stopped everything.

The token carries the user's authorities. After the permission catalogue was aligned, the super administrator's token carried enough of them to reach 21,903 bytes — eight times an ordinary user's.

Web servers accept headers of 4 to 8 KB by default. Beyond that they answer 400 Request Header Or Cookie Too Large, or 502. The symptom from the outside: sign-in fails for exactly one account, with no usable message, while every other account works. That is the kind of failure you look for on the wrong side of the stack for half a day.

The fix is three configuration directives. Finding it came down to having measured the token's size before the rollout, not after.

What I take from it

Adding isolation after the fact is no harder than having planned for it — provided you put it where it cannot be bypassed. An inherited filter and a cleared context beat 55 careful code reviews.

And an architecture almost never falls over on what it planned for. It falls over on the size of an HTTP header.

Topics

  • Multi-tenant
  • Spring Boot
  • Hibernate
  • JWT
  • Architecture
  • Sécurité

Did this help?

Share

Let us talk about what you want to build

A question, a project, or simply a conversation: write to me.