· 6 min
Enabling virtual threads silently removed tenant isolation
We found this while building feature 12 of TenantLayer, which is the innocuous-sounding "virtual threads support". The matrix entry said: Boot 3.2+ apps enable this by flag; must not break isolation. It turned out that the flag did break isolation, in the specific way that is hardest to notice.
What we expected
TenantLayer carries the current tenant across thread boundaries with a Spring
TaskDecorator. It captures the tenant on the submitting thread, restores it on the worker,
and puts the worker's previous scope back afterwards. The autoconfiguration registers it like
this:
@Bean
ThreadPoolTaskExecutorCustomizer tenantLayerTaskExecutorCustomizer() {
return executor -> executor.setTaskDecorator(new TenantTaskDecorator());
}That covers @Async. Tests passed. Done.
What actually happens
Spring Boot has a property that a lot of people are turning on right now:
spring.threads.virtual.enabled=trueWhen you set it, Boot does not put virtual threads into your ThreadPoolTaskExecutor. It
builds a different executor: a SimpleAsyncTaskExecutor configured to start a new
virtual thread per task.
And a ThreadPoolTaskExecutorCustomizer is never consulted for a SimpleAsyncTaskExecutor.
So the decorator quietly stops being applied. Every @Async method now runs on a thread with
no tenant bound.
Why nobody notices
This is the part worth internalising, because it generalises well beyond virtual threads.
Losing the tenant does not throw. There is no NullPointerException, no
IllegalStateException, nothing in the logs. The work continues, the connection it borrows
is published with the empty tenant, the row-level security policy evaluates
tenant_id = NULL, and the query returns zero rows.
Isolation held perfectly. What broke was the application, and it broke into a shape that looks exactly like "this customer has no data yet".
If your monitoring alerts on exception rates, it sees nothing. If it alerts on latency, it sees an improvement — queries that return nothing are fast.
The fix
Two lines, once you know:
@Bean
SimpleAsyncTaskExecutorCustomizer tenantLayerSimpleAsyncTaskExecutorCustomizer() {
return executor -> executor.setTaskDecorator(new TenantTaskDecorator());
}Register the decorator for both executor types. Boot picks whichever it is building; the tenant travels either way.
How we knew it was real
We did not find this by reading the Spring source. We found it because of a rule we apply to every isolation claim in this codebase: after a test passes, break the implementation and confirm the test fails.
The virtual-thread test passed first time, which is exactly when that rule earns its keep. So we deleted the new bean and ran it again:
[ERROR] VirtualThreadPropagationTest.bootsVirtualThreadExecutorKeepsTheTenant
expected: "acme"
but was: null
[enabling virtual threads must not quietly drop the task decorator]
Red. The bug was real, the fix was load-bearing, and the test was testing something.
Had we skipped that step, we would have shipped a passing test that proved nothing — and the feature list would have said "virtual threads supported" on the strength of it.
The general shape
The dangerous bugs in a tenancy layer are not the ones that throw. They are the ones where some piece of context quietly fails to arrive, and the system responds by showing you less than it should.
That failure mode has a tell: it looks like missing data, not like an error. Anywhere a framework silently swaps an implementation — an executor, a connection pool, a message listener, a cache — is a place where context can stop travelling without anything raising its voice.
The mirror image is worse. On a Kafka listener, the danger is not a lost tenant but a retained one: a long-lived consumer thread handling an untenanted record as whoever came before it. That is a cross-tenant write, and it will never show up as an empty result set.
We wrote about that one separately. The rule is the same either way: break it, and check that something notices.
TenantLayer is open source, Apache 2.0. The
test above is VirtualThreadPropagationTest,
which runs on JDK 21 in CI.