A WHERE and a Prayer

Most multi-tenant apps keep their tenants apart with a WHERE clause and good intentions. Here's how DynamoDB LeadingKeys and tenant-bound KMS encryption put the boundary underneath the query.

A glowing data cube sealed inside two concentric shells, with rows of identically sealed cubes receding into darkness

Human vigilance is a terrible security boundary, which is why the side project I’m building has a requirement that is easy to state and hard to honor: one workspace’s data must never become visible to another workspace, even when my application code is wrong.

Most multi-tenant applications rely on a query filter. Every read and write carries some version of WHERE tenant_id = ?, and the wall between customers rests on the assumption that every filter is present, correct, and never bypassed.

That makes tenant isolation a coding convention rather than an enforced boundary.

Building the system forced me to separate two questions that are often collapsed into one. Can a request reach another workspace’s rows? And if a row escapes the normal request path, is it still readable? I wanted both answers to be no.

A WHERE clause is necessary — it just isn’t a boundary

There is nothing wrong with scoping queries in application code. I still scope queries in application code. The problem is giving that code enough authority to return another tenant’s data when the scope is missing.

A forgotten filter on a new endpoint, an OR that widens a predicate farther than intended, a raw query that slips past the abstraction that used to add the tenant constraint, or a refactor that bypasses it can make the wall disappear quietly. The result may not be a failed authorization or an obvious exception; it can be a valid query returning the wrong row.

The boundary is only as strong as the most careless query anyone will ever write against the table. The filter expresses intent, but it cannot be the only thing enforcing authority.

Put the tenant in the key

The first useful move came from the DynamoDB data model. The unit of tenancy in this system is a workspace, and every record served through a workspace-scoped request shares one exact partition key:

PK                           SK                    BODY
WORKSPACE#a1b2c3d4e5f6       META#                 workspace metadata
WORKSPACE#a1b2c3d4e5f6       PLAN#plan-123         encrypted plan_json
WORKSPACE#a1b2c3d4e5f6       SETTING#theme         workspace setting
WORKSPACE#a1b2c3d4e5f6       MEMBER#user-456       encrypted identity fields

WORKSPACE#9f8e7d6c5b4a       META#                 another workspace

One key, several shapes. A workspace root record. A setting. An encrypted plan document. A member record carrying encrypted identity fields. All of it hanging off the same partition.

That claim is deliberately narrow. User profiles, invite-token lookups, and reverse membership indexes are different access patterns. They do not magically inherit workspace isolation; each needs its own restricted role and key condition. But the sensitive data reached through a workspace request has one useful property: its partition key is exactly WORKSPACE#<wid>.

If single-table DynamoDB design has a patron saint, it’s Rick Houlihan. His re:Invent talks are where a generation of us learned to stop thinking in normalized tables and start thinking in access patterns. He teaches the model for scale and cost. However, there is also a security payoff: the same key that makes the query efficient can also become the authorization boundary.

First boundary: credentials that cannot cross the partition

DynamoDB exposes the partition key to IAM through dynamodb:LeadingKeys. That lets one static role policy restrict every permitted operation to the workspace named by the caller’s session tag:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "dynamodb:GetItem",
      "dynamodb:PutItem",
      "dynamodb:UpdateItem",
      "dynamodb:DeleteItem",
      "dynamodb:Query"
    ],
    "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/app-data",
    "Condition": {
      "ForAllValues:StringLike": {
        "dynamodb:LeadingKeys": [
          "WORKSPACE#${aws:PrincipalTag/tenant}"
        ]
      },
      "Null": {
        "dynamodb:LeadingKeys": "false"
      }
    }
  }]
}

Three details are worth highlighting:

  • The STS session tag contains the bare workspace ID, not WORKSPACE#<wid>. The policy adds the prefix because # is not permitted in an STS tag value.
  • StringLike is an exact match here because the value contains no wildcard. A session tagged for a1b2c3d4e5f6 can touch only the WORKSPACE#a1b2c3d4e5f6 partition.
  • The Null guard matters. Set operators can evaluate an absent condition key in surprising ways; this clause requires a leading key to be present. Scan is not granted at all.

A workspace request now follows a small, explicit chain:

  1. 1verify token
  2. 2confirm workspace membership
  3. 3AssumeRole tenant=<wid>
  4. 4bind scoped DynamoDB + KMS clients
  5. 5execute the request

The public Lambda’s execution role has no data-table permissions. Ignore the scoped session and the operation fails closed. Asynchronous jobs assume the workspace role for the job they are processing instead of carrying cross-tenant table access.

In the WHERE-clause world, isolation is something my code remembers to do. With a leading-key condition, it is something the infrastructure refuses to violate.

The approach still depends on several controls being correct: the membership lookup, the tenant tag derived from verified membership, the trust policy governing who can set it, and each handler’s binding of the returned session. The improvement is not that responsibility vanished; it is that the security-sensitive surface shrank from every query to a small set of IAM and credential seams.

Second boundary: possession of a row is not possession of the secret

The IAM boundary answers the first question: can this request reach another workspace’s partition? It does not fully answer the second: what happens when data leaves the normal request path?

DynamoDB encryption at rest protects the storage media, but it is transparent to an authorized table reader. A broad operational role, an export, a backup workflow, or a future processing job can still obtain logical rows. For the most sensitive fields, I wanted those rows to remain unreadable without the tenant context that created them.

Those fields are encrypted in the application with an envelope key from KMS. Every operation carries an encryption context that binds the ciphertext to both the workspace and the attribute:

{
  "tenant": "WORKSPACE#a1b2c3d4e5f6",
  "attr": "plan_json"
}

On write, KMS generates a data key under that context. AES-GCM encrypts the field locally, using the same context as authenticated additional data. The stored envelope contains the wrapped data key, nonce, and ciphertext — not the plaintext key.

On read, KMS will unwrap the data key only when the caller supplies the matching context. The application then performs the AES-GCM check. Moving a plan body to another workspace, or even to another protected attribute, causes decryption to fail rather than return an incorrect result.

The KMS permission is bound to the same tenant-tagged session as the DynamoDB permission:

{
  "Effect": "Allow",
  "Action": ["kms:GenerateDataKey*", "kms:Decrypt"],
  "Resource": "<tenant-data-key-arn>",
  "Condition": {
    "StringEquals": {
      "kms:EncryptionContext:tenant":
        "WORKSPACE#${aws:PrincipalTag/tenant}"
    }
  }
}

The database credential decides which partition the request may touch. The KMS credential decides which protected bytes it may turn back into data.

Two boundaries, two failure modes

I spent a long time thinking of envelope encryption as the second wall behind the real IAM boundary — belt to the LeadingKeys suspenders. However, they are not layers of the same wall. They should be treated as peers, because they protect different paths.

  • LeadingKeys protects the request path. A bad query cannot cross the workspace partition.
  • Tenant-bound encryption protects the data object. A row obtained outside that request path is still ciphertext without the matching KMS context.

They are equal in importance, but they are not independent identity systems. Both ultimately derive the workspace from the same verified session tag. If that identity plumbing is wrong, both can be pointed at the wrong tenant. Membership verification and tag construction remain part of the trusted core.

What is independent is the failure mode. A DynamoDB authorization mistake and disclosure of encrypted bytes are different events. Requiring a request to satisfy both boundaries sharply reduces what either event can expose.

Both claims are testable at the infrastructure boundary. A session tagged for tenant A should get AccessDenied from DynamoDB, not an application-level 403, when it reaches for tenant B. Additionally, tenant A’s ciphertext should refuse to decrypt under tenant B’s context.

Stronger boundaries still send a bill

The design has real costs and constraints. It moves risk rather than erasing cost.

  • The keys come first. Single-table design requires known access patterns. A GSI or sidecar record is a new authorization surface, not a free extension of the workspace partition.
  • STS and KMS are on the path. The authorizer mints short-lived, path-specific credentials per request, and protected fields require KMS work. That adds measurable latency and cost.
  • Credential caching is security code. A multi-tenant credential cache must key, expire, and isolate sessions perfectly.
  • This is AWS-shaped. The design depends on DynamoDB condition keys, STS session tags, and KMS encryption context. Portability is not one of its strengths.

The trade is still attractive. I replaced an unbounded set of query sites that all had to be perfect with a small set of IAM, STS, and KMS seams that can be reviewed, tested, and made to fail closed.

Prepare for Thursday

The application still scopes every operation by workspace. It should. The filter chooses the data the caller intended to reach.

However, the filter no longer carries the security model by itself. IAM decides whether the credential is allowed to touch the partition. KMS decides whether the protected bytes are intelligible. The query, the authorization boundary, and the cryptographic boundary all agree on the same workspace.

I still own the configuration. AWS does not take responsibility for a bad policy simply because its engine evaluates it. The important move was narrower: enforcement left the scattered query sites and moved into infrastructure that evaluates every data operation.

That arrangement becomes more important when agents enter the request path. An agent can select tools, compose queries, and take routes that were not explicitly anticipated when an endpoint was written. In the next post, I’ll look at why an authorization boundary that remains in force below the agent is a more dependable foundation than asking the agent—or application code—to remember it on every call.

For the record, I do believe in prayer. I’d just rather not spend any of it on whether a tired developer remembered a WHERE clause on a Thursday afternoon. That’s what preparation is for.