Quarkus Multitenancy

Quarkus Multitenancy is a Quarkiverse extension that provides a reusable tenant resolution and context propagation foundation for Quarkus applications.

It standardizes how a tenant identifier is resolved from incoming requests and exposes the resolved tenant through a consistent TenantContext.

The extension is designed around a small set of abstractions:

  • TenantResolver for resolving tenant identifiers from different sources.

  • TenantContext for exposing the resolved tenant during request processing.

  • HTTP tenant resolution strategies such as header, cookie, JWT claim, and path-based resolution.

  • ORM integration for tenant-aware persistence use cases.

Status

This extension is currently in preview while the API stabilizes.

Why this extension exists

Quarkus already provides powerful building blocks for multitenancy, such as OIDC multitenancy and Hibernate ORM multitenancy.

This extension focuses on a different layer: resolving the tenant identifier consistently and making it available to the rest of the application through a shared context.

In other words, Quarkus Multitenancy does not replace OIDC multitenancy or Hibernate ORM multitenancy. It complements them by providing a reusable tenant resolution contract.

Quarkus multitenancy vs OIDC multitenancy

When developers search for Quarkus multitenancy, they often mean OIDC multitenancy, where different OIDC tenants or providers are selected depending on the request.

Quarkus Multitenancy targets a different concern.

It focuses on resolving the tenant identifier from request data such as headers, cookies, JWT claims, or path segments, and exposing that value through TenantContext.

OIDC multitenancy focuses on authentication provider selection.

Quarkus Multitenancy focuses on tenant id resolution and propagation.

The two concepts can be used together, but they solve different problems.

Quarkus multitenancy and Hibernate ORM

Hibernate ORM multitenancy focuses on database, schema, or datasource isolation.

Quarkus Multitenancy provides the tenant resolution layer that can feed tenant-aware persistence routing.

For example, a request can resolve the tenant from the X-Tenant header and expose it through TenantContext. ORM integration can then use that resolved tenant to support tenant-aware persistence use cases.

Installation

For HTTP tenant resolution:

<dependency>
    <groupId>io.quarkiverse.multitenancy</groupId>
    <artifactId>quarkus-multitenancy-http</artifactId>
    <version>${quarkus-multitenancy.version}</version>
</dependency>

For ORM integration:

<dependency>
    <groupId>io.quarkiverse.multitenancy</groupId>
    <artifactId>quarkus-multitenancy-orm</artifactId>
    <version>${quarkus-multitenancy.version}</version>
</dependency>

Quickstart

Enable HTTP tenant resolution and resolve the tenant from the X-Tenant header:

quarkus.multi-tenant.http.enabled=true
quarkus.multi-tenant.http.strategy=header
quarkus.multi-tenant.http.header-name=X-Tenant
quarkus.multi-tenant.http.default-tenant=public

Example request:

curl -H "X-Tenant: tenant1" http://localhost:8080/api/users/tenant

The current tenant will be resolved as tenant1.

Basic usage

By default, the HTTP module runs the strategy chain header,cookie. Each strategy is tried in order, and the first resolver that returns a value wins. The jwt strategy is opt-in: add it explicitly (for example quarkus.multi-tenant.http.strategy=header,jwt,cookie) because it requires a configured token verification source.

Strategy names are case-insensitive and limited to the built-in values header, jwt, cookie, and path. An unknown name (for example a typo such as heder) fails fast at startup rather than being silently ignored, so a misconfigured chain is caught immediately instead of changing tenant resolution at runtime.

Example showing the default chain in action (header resolves first because the request supplies X-Tenant):

quarkus.multi-tenant.http.enabled=true
quarkus.multi-tenant.http.header-name=X-Tenant
quarkus.multi-tenant.http.default-tenant=public

A request with:

X-Tenant: acme

will resolve the current tenant as acme.

Accessing the current tenant

Application code can inject TenantContext to access the resolved tenant during request processing.

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

import io.quarkiverse.multitenancy.core.runtime.context.TenantContext;

@Path("/tenant")
public class TenantResource {

    @Inject
    TenantContext tenantContext;

    @GET
    public String currentTenant() {
        return tenantContext.getTenantId().orElse("public");
    }
}

Running tenant-scoped background work

Inject TenantContextRunner when scheduled jobs, startup observers, or other background code needs to run with a tenant temporarily bound:

import jakarta.inject.Inject;

import io.quarkiverse.multitenancy.core.runtime.context.TenantContextRunner;
import io.quarkus.scheduler.Scheduled;

public class TenantJob {

    @Inject
    TenantContextRunner tenantRunner;

    @Scheduled(every = "1h")
    void refreshTenantData() {
        tenantRunner.runAsTenant("acme", () -> refreshData());
    }
}

runAsTenant accepts either a Runnable or a Supplier<T>. It uses the existing CDI request context when one is active and creates one only when necessary. The previous tenant is restored after the callback completes, including when the callback throws, so nested and sequential calls do not leak tenant state.

TenantContextRunner is synchronous only. Returning a CompletionStage or a Mutiny Uni from the callback is rejected immediately, because the callback would return before the asynchronous work runs and the tenant would already have been restored. For asynchronous work, rely on the propagation contract below instead.

Reactive and asynchronous tenant propagation

TenantContext is a request-scoped bean, and Quarkus binds the request context to the Vert.x duplicated context rather than to a thread. Any work that Quarkus dispatches on your behalf as part of the same request keeps the resolved tenant available, even after a thread switch. Work that jumps to a thread with no duplicated context loses it.

The distinction that matters is not "synchronous versus asynchronous". It is whether the executing thread still carries the request’s duplicated context.

This propagation is provided by Quarkus REST, Vert.x, and SmallRye context propagation, not by this extension. The extension only exposes the resolved tenant through a request-scoped TenantContext; whether that context survives a given thread switch is decided by the underlying Quarkus runtime. The table below documents the behavior of the current Quarkus release so you know what to rely on; a future Quarkus change to context propagation could shift a row, and that would be a Quarkus change rather than a multitenancy regression.

The boundaries below all work in a Quarkus REST application with no added dependency: quarkus-rest already brings quarkus-smallrye-context-propagation transitively, which is what makes the ManagedExecutor case below work out of the box.

Boundary Tenant available Notes

Mutiny Uni pipeline (transform, chain), including emitOn the Quarkus worker pool

Yes

The duplicated context travels with the pipeline. Failure and cancellation callbacks run with the tenant still bound.

@Blocking endpoints and worker-thread offload

Yes

Quarkus dispatches the worker thread on the request’s duplicated context.

CompletionStage run on a MicroProfile ManagedExecutor

Yes

The ManagedExecutor captures and restores the context around each task. Its provider, quarkus-smallrye-context-propagation, is already present transitively in a quarkus-rest application.

Submitting work yourself to a raw JDK executor, for example CompletableFuture.supplyAsync(…​, Executors.newFixedThreadPool(…​))

No

The task runs on a thread with no duplicated context. Reading TenantContext there throws ContextNotActiveException. The supported way to keep the tenant is a Mutiny pipeline or a ManagedExecutor; a raw executor you submit to yourself is the unsupported case.

Concurrent requests carrying different tenants stay isolated: each request has its own duplicated context, so one request never observes another request’s tenant across a supported boundary. When the request completes, whether it succeeds, fails, or is cancelled, its tenant context is torn down and does not leak onto a later request that reuses the same worker thread.

Reactive endpoints

A Mutiny Uni pipeline keeps the tenant available across its stages, including after emitting on the worker pool, with no extra dependency:

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

import io.quarkiverse.multitenancy.core.runtime.context.TenantContext;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;

@Path("/orders")
public class OrderResource {

    @Inject
    TenantContext tenantContext;

    @GET
    public Uni<String> currentTenant() {
        return Uni.createFrom().item("start")
                .emitOn(Infrastructure.getDefaultWorkerPool())
                // Still the same request: the tenant is available after the thread switch.
                .onItem().transform(ignored -> tenantContext.getTenantId().orElse("public"));
    }
}

Context-aware CompletionStage

To keep the tenant across a CompletionStage, run it on a MicroProfile ManagedExecutor. In a Quarkus REST application no dependency needs to be added: quarkus-rest brings quarkus-smallrye-context-propagation transitively. Only a non-REST Quarkus application would add it explicitly:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-smallrye-context-propagation</artifactId>
</dependency>
import java.util.concurrent.CompletionStage;

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

import org.eclipse.microprofile.context.ManagedExecutor;

import io.quarkiverse.multitenancy.core.runtime.context.TenantContext;

@Path("/report")
public class ReportResource {

    @Inject
    TenantContext tenantContext;

    @Inject
    ManagedExecutor managedExecutor;

    @GET
    public CompletionStage<String> report() {
        // Runs on a context-aware thread: the tenant is still available here.
        return managedExecutor.supplyAsync(() -> tenantContext.getTenantId().orElse("public"));
    }
}

Having context propagation on the classpath is not enough on its own: propagation happens only when the work runs on the ManagedExecutor. Submitting to a raw executor, or to the injectable core ExecutorService, still loses the context.

Crossing an unsupported boundary

If you must hand work to an executor that is not context-aware, capture the tenant id while the request context is still active and pass the plain value across the boundary:

String tenantId = tenantContext.getTenantId().orElse("public");
rawExecutor.submit(() -> doWorkFor(tenantId)); // pass the value, not the context

Resolution strategies

Header

quarkus.multi-tenant.http.strategy=header
quarkus.multi-tenant.http.header-name=X-Tenant
quarkus.multi-tenant.http.strategy=cookie
quarkus.multi-tenant.http.cookie-name=tenant_cookie

Path

Path-based tenant resolution is useful for SaaS-style URLs where the tenant is part of the request path.

For example:

/t/acme/products

can resolve the tenant as acme.

Path-based resolution is opt-in. Enable it by adding path to the strategy list:

quarkus.multi-tenant.http.strategy=path,header,cookie
quarkus.multi-tenant.http.path-pattern=^/t/([^/]+)(?:/|$)
quarkus.multi-tenant.http.path-group=1

The path-pattern property defines the regular expression used to match the request path.

The path-group property defines which capturing group contains the tenant identifier.

With the default pattern:

^/t/([^/]+)(?:/|$)

the first capturing group extracts the tenant id from paths such as:

/t/acme
/t/acme/products
/t/customer-123/orders

This strategy does not rewrite the URL or perform request routing. It only extracts the tenant identifier and stores it in TenantContext.

JWT claim

The JWT resolver reads the tenant from a claim in a verified bearer token. SmallRye JWT performs the signature check before the resolver runs, so applications must configure a verification source before enabling the strategy.

quarkus.multi-tenant.http.strategy=jwt
quarkus.multi-tenant.http.jwt-claim-name=tenant

mp.jwt.verify.publickey.location=publicKey.pem
mp.jwt.verify.publickey.algorithm=RS256
mp.jwt.verify.issuer=https://my-issuer.example.com

Operator contract

  • Configure SmallRye JWT (any combination of mp.jwt.verify.publickey.location, mp.jwt.verify.publickey, or related properties) or Quarkus OIDC (quarkus.oidc.auth-server-url). The extension fails to start if jwt is in the strategy chain and neither source is configured. Set quarkus.multi-tenant.http.jwt.skip-startup-check=true to opt out — for example when wiring a custom JsonWebToken producer.

  • Pin the signature algorithm with mp.jwt.verify.publickey.algorithm. Never enable both symmetric and asymmetric verifiers at once.

  • The default-tenant fallback only applies when no strategy in the chain produces a result. A bearer token that fails verification or is missing the required claim rejects the request with HTTP 401; it does not silently fall back to the default tenant.

Resolution outcomes

Input Result

No Authorization: Bearer … header

JWT strategy returns NotApplicable; the dispatcher tries the next strategy.

Verified token whose jwt-claim-name claim is a non-blank string

Tenant is resolved to that string.

Verified token but the claim is missing, non-string, or blank

Request rejected with HTTP 401.

Bearer header present but the token cannot be verified (bad signature, wrong issuer, expired, …)

Request rejected with HTTP 401.

Custom TenantResolver precedence

In addition to the configured built-in strategy chain, the extension also resolves tenants through custom TenantResolver beans first.

The dispatch order is:

  1. Custom resolvers (CDI beans whose name() is empty or unknown), ordered by descending jakarta.annotation.Priority.

  2. Built-in resolvers listed by quarkus.multi-tenant.http.strategy.

That means custom resolvers can intentionally override header/cookie/path/jwt behavior. If a custom resolver returns resolved or rejected, dispatch stops immediately. Only when the custom resolver returns notApplicable does the framework continue with the built-in chain.

A common pattern is an override that only applies on a special header:

package io.github.demo.resolver;

import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.container.ContainerRequestContext;

import io.quarkiverse.multitenancy.core.runtime.api.TenantResolution;
import io.quarkiverse.multitenancy.core.runtime.api.TenantResolutionContext;
import io.quarkiverse.multitenancy.core.runtime.api.TenantResolver;

@ApplicationScoped
@Priority(200)
public class HeaderOverrideTenantResolver implements TenantResolver {

    @Override
    public TenantResolution resolve(TenantResolutionContext context) {
        return context.get(ContainerRequestContext.class)
            .filter(req -> {
                String value = req.getHeaderString("X-Tenant-Override");
                return value != null && !value.isBlank();
            })
            .map(req -> req.getHeaderString("X-Tenant-Override"))
            .map(TenantResolution::resolved)
            .orElseGet(TenantResolution::notApplicable);
    }
}

Behavior in this example:

  • Requests that send X-Tenant-Override are forced to that tenant and built-in strategies are skipped.

  • Requests without that header continue through the configured built-in strategy list.

Higher numeric priorities run first. A custom resolver without @Priority uses priority 0. Equal-priority resolvers are ordered by fully qualified implementation class name. Defining multiple beans with the same implementation type and priority fails startup; assign distinct priorities to make their precedence explicit.

Configuration

The HTTP module currently exposes configuration under:

quarkus.multi-tenant.http.*

Main options include:

Property Default Description

quarkus.multi-tenant.http.enabled

true

Enables or disables HTTP tenant resolution.

quarkus.multi-tenant.http.strategy

header,cookie

Ordered list of built-in tenant resolution strategies. jwt and path are opt-in.

quarkus.multi-tenant.http.header-name

X-Tenant

Header name used by the header resolver.

quarkus.multi-tenant.http.jwt-claim-name

tenant

JWT claim name used by the JWT resolver.

quarkus.multi-tenant.http.cookie-name

tenant_cookie

Cookie name used by the cookie resolver.

quarkus.multi-tenant.http.default-tenant

public

Tenant used when no resolver returns a value.

quarkus.multi-tenant.http.path-pattern

/t/([/]+)(?:/|$)

Regex used by the path resolver.

quarkus.multi-tenant.http.path-group

1

Capturing group used to extract the tenant from the path.

quarkus.multi-tenant.http.tenant-id.validation-enabled

true

Validates a resolved tenant id against the length and pattern policy below.

quarkus.multi-tenant.http.tenant-id.max-length

64

Maximum length of a resolved tenant id. A longer id is rejected with reject-status.

quarkus.multi-tenant.http.tenant-id.pattern

[A-Za-z0-9_-]+

Pattern a resolved tenant id must match in full. A non-matching id is rejected with reject-status.

quarkus.multi-tenant.http.tenant-id.reject-status

400

HTTP status used when a resolved tenant id fails the length or pattern policy. Defaults to 400 (malformed request input). Does not affect the 401 returned for an authentication-related rejection. Set to 401 to restore the previous behaviour. Only 4xx (400-499) values are supported; any other value fails fast at startup.

Tenant identifier validation

Once a resolver produces a tenant id, the HTTP filter validates it before publishing it to TenantContext. The same policy applies to every resolver — built-in and custom — because each Resolved outcome passes through the filter.

quarkus.multi-tenant.http.tenant-id.validation-enabled=true
quarkus.multi-tenant.http.tenant-id.max-length=64
quarkus.multi-tenant.http.tenant-id.pattern=[A-Za-z0-9_-]+
quarkus.multi-tenant.http.tenant-id.reject-status=400

An identifier that exceeds max-length or does not match pattern in full is rejected with reject-status (HTTP 400 by default). It never reaches TenantContext, and the request does not fall back to default-tenant. This keeps untrusted input out of downstream consumers such as log lines, SQL parameters, and ORM tenant lookups.

The identifier __bootstrap is reserved for internal Hibernate ORM startup. It is always rejected when supplied by a header, cookie, path, JWT claim, custom resolver, or default-tenant, even when configurable tenant-id validation is disabled.

The default 400 reflects that a malformed tenant id is bad request input, which is distinct from an authentication failure: an untrusted or malformed bearer token still produces a Rejected outcome and returns 401 (see the JWT claim strategy), regardless of reject-status. A valid token whose tenant claim value breaks the policy is a syntactic failure and is rejected with reject-status. Set reject-status=401 to keep a single status for both. Only client-error (4xx) values are accepted: a reject-status outside the 400-499 range is a misconfiguration and fails fast at startup.

When an identifier is rejected, only a sanitized, length-capped form is written to the log, with control characters (including CR/LF) replaced, to prevent log injection.

Validation is enabled by default with a conservative pattern. If your tenant identifiers use a richer character set (for example dots, colons, or non-ASCII characters) or are longer than 64 characters, widen pattern and max-length, or set validation-enabled=false to turn the check off.

The same policy is applied to default-tenant at startup: because the fallback tenant is published to TenantContext just like a resolved identifier, the application fails to start when default-tenant violates max-length or pattern (for example default-tenant=my@tenant against the default pattern, or an empty value). This surfaces the misconfiguration at boot instead of on the first unresolved request. Setting validation-enabled=false skips this startup check as well.

ORM configuration

The ORM module integrates the resolved tenant with Quarkus Hibernate ORM multitenancy.

When quarkus-multitenancy-http and quarkus-multitenancy-orm are installed together, the HTTP tenant-resolution pipeline owns request resolution. The ORM header filter acts only as a fallback and does nothing when TenantContext already contains a tenant, so it cannot overwrite a tenant selected by the HTTP strategy chain.

For ORM-only applications the fallback keeps the historical X-Tenant behavior by default and can be configured independently:

quarkus.multi-tenant.orm.header-filter.enabled=true
quarkus.multi-tenant.orm.header-filter.header-name=X-Tenant
quarkus.multi-tenant.orm.tenant-id.validation-enabled=true
quarkus.multi-tenant.orm.tenant-id.max-length=64
quarkus.multi-tenant.orm.tenant-id.pattern=[A-Za-z0-9_-]+

The ORM-only fallback uses the same core validation implementation as HTTP resolution, including reserved-id rejection and sanitized logging.

For database-based multitenancy, enable Hibernate ORM multitenancy and define one datasource per tenant:

quarkus.hibernate-orm.multitenant=DATABASE

quarkus.datasource.__bootstrap.db-kind=postgresql
quarkus.datasource.__bootstrap.jdbc.url=jdbc:postgresql://localhost:5433/postgres
quarkus.datasource.__bootstrap.username=user1
quarkus.datasource.__bootstrap.password=pass1

quarkus.datasource.tenant1.db-kind=postgresql
quarkus.datasource.tenant1.jdbc.url=jdbc:postgresql://localhost:5433/tenant1
quarkus.datasource.tenant1.username=user1
quarkus.datasource.tenant1.password=pass1

quarkus.datasource.tenant2.db-kind=postgresql
quarkus.datasource.tenant2.jdbc.url=jdbc:postgresql://localhost:5434/tenant2
quarkus.datasource.tenant2.username=user2
quarkus.datasource.tenant2.password=pass2

The ORM adapter uses the tenant id stored in TenantContext to resolve the current Hibernate ORM tenant.

The special __bootstrap tenant is used only as the internal default during Hibernate ORM startup, before a request tenant is available. It is not a Hibernate root tenant and cannot be selected through TenantContext or an external resolver.

FAQ

Is this the same as OIDC multitenancy?

No.

OIDC multitenancy focuses on selecting authentication provider configuration.

Quarkus Multitenancy focuses on resolving and propagating the tenant identifier used by the application.

Does this replace Hibernate ORM multitenancy?

No.

It complements Hibernate ORM multitenancy by providing consistent tenant resolution input for persistence routing.

Header-based resolution is useful for service-to-service or gateway-driven architectures.

JWT claim resolution is useful for authentication-centric flows where the tenant id is part of the token claims.

Cookie-based resolution is useful for browser applications.

Path-based resolution is useful for SaaS-style URLs such as /t/acme/products.

Demo

A demo application is available in the repository and can be used to test HTTP and ORM tenant resolution locally.

git clone https://github.com/quarkiverse/quarkus-multitenancy
cd quarkus-multitenancy/quarkus-multitenancy-demo
mvn quarkus:dev

Security note

The JWT tenant strategy verifies bearer tokens through SmallRye JWT or OIDC before reading the tenant claim, and rejects requests with HTTP 401 whenever a token is present but cannot be trusted. Operators are responsible for configuring a verification source and pinning the signature algorithm — see the operator contract under the JWT claim strategy.