A multi-tenant agent serves many customer organisations from one deployment. Tenant data separation is the guarantee that nothing one tenant stores can be read, searched, inferred or deleted by another. Here "stores" covers conversations, session state, uploaded files, long-term memory, embeddings, caches and logs. A failure is not a performance problem. It is a data breach, usually discovered by a customer.

ADK Java does not do this for you, and the reason is visible in its interfaces. This article starts from what the session, artifact and memory services actually key their data on, which is an application name and a user id with no tenant field. It then shows the hazards that follow, a layout that closes them, guard decorators written against the real interfaces, the side channels outside ADK that leak just as easily, and a test suite that proves separation instead of assuming it.

What ADK Java actually keys data on

Every ADK Java storage service is addressed by the same triple. BaseSessionService methods take (appName, userId, sessionId): getSession, listEvents and deleteSession, with listSessions(appName, userId) one level up. BaseArtifactService uses saveArtifact(appName, userId, sessionId, filename, part) and the matching load, list and delete calls. BaseMemoryService has just two methods: addSessionToMemory(session) and searchMemory(appName, userId, query). The Runner is bound to one application name and is called with runAsync(userId, sessionId, message, runConfig).

Session state adds a second axis through key prefixes defined in State. Keys beginning user: persist across one user's sessions within an app. Keys beginning app: are shared by every user and every session of that app. Keys beginning temp: are never persisted. Unprefixed keys belong to one session. The GCS artifact service maps the triple onto object paths of the form appName/userId/sessionId/filename/version, or appName/userId/user/filename/version when the filename starts with user:.

StoreIsolation key ADK usesWhat leaks if tenants share an appName
Sessions and eventsappName, userId, sessionIdcolliding user ids see each other's history
app: stateappName onlyone tenant's settings or notes appear for all tenants
user: state and artifactsappName, userIdcross-tenant reads when user ids collide
Memory searchappName, userIdrecall of another tenant's conversations
Your vector index, caches, logswhatever you choseeverything, unless the tenant is in the key

The conclusion is direct. The only isolation boundary ADK gives you is the app name. If two tenants share one, nothing in the framework keeps them apart.

Three ways tenants end up sharing data

Teams adding multi-tenancy to an existing agent usually make one of three mistakes.

  1. Shared app name, raw user ids. User ids come from each tenant's identity provider, and jsmith or admin exist in many of them. Two tenants with the same user id share sessions, user: state, user-scoped artifacts and memory.
  2. Shared app name, composite user ids such as acme:jsmith. This fixes user-level collisions but leaves app: state global. A tool that caches an app:pricing_overrides map for one customer will serve it to every customer. Composite ids also break whenever the separator can appear inside an id.
  3. Unvalidated identifiers in paths. Object-store prefixes are built by joining identifiers with /. A user id containing a slash, or a filename built from model output, can produce a prefix that overlaps another principal's prefix. Listing calls are prefix scans, so overlap means disclosure.

A fourth hazard sits outside the store layer. If the tenant id is taken from a request body, a header the client controls, or anything the model can write, then an attacker or a prompt injection can select it. The tenant must come from the authenticated credential and nowhere else. The pattern is the same one used in authorisation at the agent boundary.

The layout: per-tenant app names, guards and placement

The layout that closes these hazards has four rules. First, give each tenant its own app name, derived as <baseApp>.<tenantId> with a separator that identifiers may not contain. Second, keep one runner per tenant app name, created lazily and cached. Third, wrap every storage service in a guard that rejects any call whose app name or identifiers do not belong to the guard's tenant. Fourth, place the data physically according to the tenant's tier, either in a pooled store or in a siloed one.

Tenant data separation around an ADK Java agentRequestbearer tokenAuth boundarytenant from token onlyTenantRunnersrunner per support.acmerunAsyncAgent + toolsnever sees tenant idGuarded sessionscheck appName, keysGuarded artifactscheck ids, pathsGuarded memorysearch scopedPlacement routertenant tier to backendPooled storeshared DB, RLS on appSiloed storeown schema or bucketPer-tenant KMS keyenvelope encryptionSide channelsvectors, caches, logstenant in every keyand every log line
The tenant is fixed at the auth boundary and selects a runner. Guards re-check every storage call, and a placement router decides where the bytes live. Side channels carry the tenant in their keys.

Per-tenant app names make app: state tenant-scoped, which is usually what product teams wanted when they first used it. The guard is defence in depth. Even if a bug passes the wrong app name, or a tool constructs its own service call, the guard fails closed. The agent and its tools never handle the tenant id directly, so the model cannot be talked into switching tenants.

Guard decorators in code

The scope object validates identifiers once and derives the app name. The guard implements BaseSessionService by delegation. The abstract methods it must implement are the ones shown, and appendEvent is delegated explicitly because persistent implementations override it.

public class TenantViolation extends RuntimeException {    // our own type, not ADK's
  public TenantViolation(String message) { super(message); }
}

public record TenantScope(String tenantId, String appName) {
  private static final Pattern ID = Pattern.compile("[A-Za-z0-9_-]{1,128}");

  public static TenantScope of(String baseApp, String tenantId) {
    return new TenantScope(requireId(tenantId), requireId(baseApp) + "." + tenantId);
  }

  static String requireId(String s) {
    if (s == null || !ID.matcher(s).matches()) throw new TenantViolation("bad id");
    return s;
  }

  void check(String app, String userId) {
    if (!appName.equals(app)) throw new TenantViolation("app " + app + " not in " + tenantId);
    requireId(userId);
  }
}

public final class TenantGuardedSessionService implements BaseSessionService {
  private final BaseSessionService delegate;
  private final TenantScope scope;

  public TenantGuardedSessionService(BaseSessionService delegate, TenantScope scope) {
    this.delegate = delegate;
    this.scope = scope;
  }

  @Override
  public Single<Session> createSession(String appName, String userId,
      @Nullable ConcurrentMap<String, Object> state, @Nullable String sessionId) {
    scope.check(appName, userId);
    if (sessionId != null) TenantScope.requireId(sessionId);
    return delegate.createSession(appName, userId, state, sessionId);
  }

  @Override
  public Maybe<Session> getSession(String appName, String userId, String sessionId,
      Optional<GetSessionConfig> config) {
    scope.check(appName, userId);
    return delegate.getSession(appName, userId, sessionId, config)
        .filter(s -> scope.appName().equals(s.appName()));   // re-check the result
  }

  @Override
  public Single<ListSessionsResponse> listSessions(String appName, String userId) {
    scope.check(appName, userId);
    return delegate.listSessions(appName, userId);
  }

  @Override
  public Completable deleteSession(String appName, String userId, String sessionId) {
    scope.check(appName, userId);
    return delegate.deleteSession(appName, userId, sessionId);
  }

  @Override
  public Single<ListEventsResponse> listEvents(String appName, String userId, String sessionId) {
    scope.check(appName, userId);
    return delegate.listEvents(appName, userId, sessionId);
  }

  @Override
  public Single<Event> appendEvent(Session session, Event event) {
    scope.check(session.appName(), session.userId());
    return delegate.appendEvent(session, event);
  }

  @Override
  public Completable closeSession(Session session) {
    return delegate.closeSession(session);
  }
}

Apply the same pattern to artifacts and memory. Check the app name and validate the user and session ids on every artifact call, and reject filenames containing .. or a leading slash. For memory, check session.appName() in addSessionToMemory and the app name argument in searchMemory. The runner cache keeps construction in one place:

public final class TenantRunners {
  private final ConcurrentHashMap<String, Runner> runners = new ConcurrentHashMap<>();
  private final Function<TenantScope, Runner> factory;   // wraps services, builds the Runner

  public TenantRunners(Function<TenantScope, Runner> factory) { this.factory = factory; }

  public Runner forTenant(String tenantId) {
    TenantScope scope = TenantScope.of("support", tenantId);
    return runners.computeIfAbsent(scope.appName(), k -> factory.apply(scope));
  }
}

// Request path: the tenant comes from the verified token, never from the body.
Principal who = tokenVerifier.verify(request.bearerToken());
Flowable<Event> events = runners.forTenant(who.tenantId())
    .runAsync(who.userId(), sessionId, message, runConfig);

The factory builds the guarded services for that tenant, passes them to the Runner using whichever constructor or builder your ADK version provides, and chooses physical backends through the placement router. A pooled tenant shares a database whose rows are keyed by app name. Add row-level security on the app name column, so that a connection opened for one tenant cannot read another tenant's rows even with a hand-written query. The storage side of that design is covered in the ADK Postgres session schema. A siloed tenant gets its own schema or database, and its own artifact bucket or prefix with a dedicated encryption key.

The side channels outside ADK

Most real cross-tenant leaks happen outside the three ADK services:

  • Vector indexes. A retrieval tool that queries a shared index with a metadata filter is one missing filter away from a breach. Prefer one namespace or collection per tenant, chosen by the tool from its tenant scope rather than from model arguments.
  • Caches. A response or embedding cache keyed only by prompt text will serve tenant A's answer, which may quote A's documents, to tenant B. Put the tenant id first in every cache key.
  • Logs, traces and evaluation sets. Prompts and tool outputs end up in observability backends and in datasets copied for evaluation. Tag every record with the tenant, redact by default, and keep exported evaluation data per tenant.
  • Encryption keys. Envelope-encrypt artifacts and memory with a per-tenant key. Offboarding then becomes key destruction plus deletion, which makes the backups unreadable as well. The deletion side is covered in memory privacy and the right to forget.
  • Usage records. Billing pipelines join events across tenants by design. Keep them aggregate-only, as described in Agent Tenant Billing, in depth.

Worked example: untangling a shared support agent

Consider a support agent run as a single app named support for two customers, Acme and Globex. Acme's administrator uses a tool that stores escalation contacts under app:escalation_contacts. Within an hour, Globex users are told to email an Acme manager. In the same week a Globex employee whose id is jsmith sees a session list containing Acme's jsmith's conversations.

The fix has four steps. First, introduce TenantScope and the guards, and serve new sessions from support.acme and support.globex. Second, migrate existing rows by rewriting the app name according to a userId -> tenant mapping taken from the identity provider. Quarantine any session whose owner cannot be resolved instead of guessing. Third, split the app: keys per tenant, delete the global copies, and add a rule to the session guard that rejects app: keys in an event's state delta unless the event comes from the admin path. Fourth, move the GCS objects to the new prefixes and rotate in per-tenant keys. Finish by running the separation tests below against production-like data and recording the incident in the audit log.

Proving separation with canary tests

Separation should be proved by tests that try to break it. Seed each tenant with unique canary strings, then attempt every read path as the other tenant:

@Test
void otherTenantCannotReachCanary() {
  String canary = "canary-" + UUID.randomUUID();
  TenantScope acme = TenantScope.of("support", "acme");
  TenantScope globex = TenantScope.of("support", "globex");
  BaseSessionService acmeSessions = guarded(acme), globexSessions = guarded(globex);

  Session s = acmeSessions.createSession(acme.appName(), "jsmith",
      Map.<String, Object>of("user:note", canary), null).blockingGet();

  // Same user id, other tenant: the guard must refuse the foreign app name.
  assertThrows(TenantViolation.class, () -> globexSessions
      .getSession(acme.appName(), "jsmith", s.id(), Optional.empty()).blockingGet());
  // And the other tenant's own namespace must not contain the canary anywhere.
  assertFalse(dumpAll(globexSessions, globex.appName()).contains(canary));
  assertFalse(searchMemoryAs(globex, "jsmith", canary).contains(canary));
  assertFalse(vectorSearchAs(globex, canary).contains(canary));
}

Run the suite in CI, and run it nightly against staging with real backends. Add a production probe that writes canaries for a synthetic tenant and alerts if any other tenant's search ever returns one.

Failure modes

  • Tenant id from the request body. Any client can claim any tenant. Derive it from the verified token.
  • Guard bypass through a raw client. A tool that opens its own database connection or bucket client skips every guard. Give tools only scoped handles.
  • Background jobs without scope. Summarisers, memory ingestion and reindexers run outside requests. Each job needs a TenantScope passed in explicitly, and the guard should reject calls that arrive without one.
  • Shared state in agent instances. A tool object that caches the last result in a field is shared across tenants when the agent definition is shared. Keep tool objects stateless, or create them per tenant.
  • Unbounded runner cache. One runner per tenant across thousands of tenants costs memory. Make the cache evictable if runners hold heavy resources.

Trade-offs

LayoutIsolationCost and effortFits
Shared appName, composite user idsweak: app: is globallowestnever for external customers
App name per tenant, pooled store, RLSlogical, enforced twicelowmost SaaS tenants
App name per tenant, siloed schema, bucket and keystrong, simple deletionmediumregulated or large tenants
Deployment per tenantstrongesthigh, slow to roll outcontractual dedicated tenants

Most platforms run a mix: pooled by default, siloed for tenants who pay for it or whose regulator requires it. The placement router is what makes moving a tenant between tiers a data migration rather than a code change.

What to do next

  1. Inventory every place your agent writes data: the three ADK services, vector indexes, caches, logs, traces and evaluation exports.
  2. Check whether any two tenants share an app name. If they do, list every app: key in use.
  3. Introduce TenantScope, per-tenant app names and guard decorators for sessions, artifacts and memory.
  4. Make the tenant come only from the verified credential, and give tools scoped handles.
  5. Put the tenant id into every cache key, index namespace and log record.
  6. Write the canary separation tests, run them in CI, and add a production canary probe.
  7. Choose pooled or siloed placement per tier, with per-tenant keys for siloed tenants.
Key takeaway: ADK Java isolates data only by app name and user id, and app: state is shared by every user of an app, so tenants that share an app name share data. Give each tenant its own app name and runner, take the tenant only from the verified credential, wrap every storage service in a guard that fails closed, put the tenant into every cache key, index and log record outside ADK, and prove separation with canary tests rather than assuming it.